feat: compact logout-time cell and optional login block after logout time
Owners set the daily logout time in a modal and can block re-login until the next Tehran day.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -375,6 +375,8 @@ model Membership {
|
||||
priorArchiveLens String @default("current")
|
||||
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
||||
logoutTime String?
|
||||
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
|
||||
blockLoginAfterLogout Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
export const WORKDAY_ENDED_MESSAGE =
|
||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||
|
||||
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
|
||||
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
|
||||
|
||||
type RecordInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
@@ -61,6 +64,18 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
|
||||
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد میشود. */
|
||||
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, userId, isActive: true },
|
||||
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
|
||||
});
|
||||
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
|
||||
if (loggedInAfterCutoff(at, membership.logoutTime)) {
|
||||
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
async recordLogout(actor: Actor) {
|
||||
if (actor.viewAsReadOnly) return { ok: true };
|
||||
const at = new Date();
|
||||
@@ -81,14 +96,20 @@ export class AttendanceService {
|
||||
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 },
|
||||
select: { logoutTime: true, role: true, blockLoginAfterLogout: 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;
|
||||
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
|
||||
if (
|
||||
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
|
||||
!membership?.blockLoginAfterLogout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
|
||||
@@ -723,6 +723,9 @@ export class AuthService {
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
if (!opts?.skipLastLogin) {
|
||||
await this.attendance.assertLoginAllowed(organizationId, userId);
|
||||
}
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
|
||||
@@ -95,6 +95,11 @@ export class UpdateLogoutTimeDto {
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
|
||||
logoutTime?: string | null;
|
||||
|
||||
/** بدون مقدار = بدون تغییر */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
blockLoginAfterLogout?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserVisibilityDto {
|
||||
|
||||
@@ -79,6 +79,7 @@ export class UsersController {
|
||||
user.role,
|
||||
id,
|
||||
dto.logoutTime ?? null,
|
||||
dto.blockLoginAfterLogout,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ type MembershipRow = {
|
||||
dataVisibilityScopes?: unknown;
|
||||
priorArchiveEnabled?: boolean;
|
||||
logoutTime?: string | null;
|
||||
blockLoginAfterLogout?: boolean;
|
||||
createdAt: Date;
|
||||
orgRole: { id: string; name: string; color: string } | null;
|
||||
user: {
|
||||
@@ -248,6 +249,7 @@ export class UsersService {
|
||||
actorRole: UserRole,
|
||||
targetId: string,
|
||||
logoutTime: string | null,
|
||||
blockLoginAfterLogout?: boolean,
|
||||
): Promise<OrgUserDto> {
|
||||
if (actorRole !== 'owner') {
|
||||
throw new ForbiddenException('فقط مالک میتواند ساعت خروج را تعیین کند');
|
||||
@@ -263,7 +265,15 @@ export class UsersService {
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { logoutTime: value },
|
||||
data: {
|
||||
logoutTime: value,
|
||||
// بدون ساعت خروج، مسدودسازی معنی ندارد
|
||||
...(!value
|
||||
? { blockLoginAfterLogout: false }
|
||||
: blockLoginAfterLogout !== undefined
|
||||
? { blockLoginAfterLogout }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
@@ -533,6 +543,8 @@ export class UsersService {
|
||||
: parseVisibilityScopes(row.dataVisibilityScopes),
|
||||
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
|
||||
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
|
||||
blockLoginAfterLogout:
|
||||
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user