feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
RoutineCategoryDto,
|
||||
RoutineContextDto,
|
||||
RoutineDto,
|
||||
} from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { elevated } from '../common/record-scope';
|
||||
import {
|
||||
ChangeRoutineAssigneeDto,
|
||||
ListRoutinesQueryDto,
|
||||
SaveRoutineCategoryDto,
|
||||
SaveRoutineDto,
|
||||
} from './dto';
|
||||
import { RoutineGeneratorService } from './routine-generator.service';
|
||||
import { routineInclude, toCategoryDto, toRoutineDto } from './routine-mapper';
|
||||
import {
|
||||
type RoutineScope,
|
||||
assertManager,
|
||||
canManage,
|
||||
resolveRoutineScope,
|
||||
} from './routine-scope';
|
||||
import { addDays, dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates';
|
||||
|
||||
const DEFAULT_CATEGORIES: Array<{ name: string; color: string }> = [
|
||||
{ name: 'فروش', color: '#14b8a6' },
|
||||
{ name: 'پیگیری', color: '#3b82f6' },
|
||||
{ name: 'لید', color: '#8b5cf6' },
|
||||
{ name: 'مشتری', color: '#f59e0b' },
|
||||
{ name: 'گزارشدهی', color: '#f43f5e' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class RoutinesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly generator: RoutineGeneratorService,
|
||||
) {}
|
||||
|
||||
scope(actor: Actor): Promise<RoutineScope> {
|
||||
return resolveRoutineScope(this.prisma, actor);
|
||||
}
|
||||
|
||||
/* ───────── دستهبندیها ───────── */
|
||||
|
||||
async categories(organizationId: string): Promise<RoutineCategoryDto[]> {
|
||||
let rows = await this.prisma.routineCategory.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
if (!rows.length) {
|
||||
await this.prisma.routineCategory.createMany({
|
||||
data: DEFAULT_CATEGORIES.map((c, i) => ({ organizationId, ...c, sortOrder: i })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
rows = await this.prisma.routineCategory.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}
|
||||
return rows.map(toCategoryDto);
|
||||
}
|
||||
|
||||
async createCategory(actor: Actor, dto: SaveRoutineCategoryDto) {
|
||||
assertManager(await this.scope(actor));
|
||||
try {
|
||||
const row = await this.prisma.routineCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name: dto.name.trim(),
|
||||
color: dto.color || '#14b8a6',
|
||||
sortOrder: dto.sortOrder ?? 99,
|
||||
},
|
||||
});
|
||||
return toCategoryDto(row);
|
||||
} catch {
|
||||
throw new BadRequestException('دستهبندی با این نام وجود دارد');
|
||||
}
|
||||
}
|
||||
|
||||
async updateCategory(actor: Actor, id: string, dto: SaveRoutineCategoryDto) {
|
||||
assertManager(await this.scope(actor));
|
||||
await this.findCategory(actor.organizationId, id);
|
||||
const row = await this.prisma.routineCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
...(dto.color ? { color: dto.color } : {}),
|
||||
...(dto.sortOrder != null ? { sortOrder: dto.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
return toCategoryDto(row);
|
||||
}
|
||||
|
||||
async deleteCategory(actor: Actor, id: string) {
|
||||
assertManager(await this.scope(actor));
|
||||
await this.findCategory(actor.organizationId, id);
|
||||
await this.prisma.routineCategory.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async findCategory(organizationId: string, id: string) {
|
||||
const row = await this.prisma.routineCategory.findFirst({ where: { id, organizationId } });
|
||||
if (!row) throw new NotFoundException('دستهبندی پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
/* ───────── زمینه (کاربران، تیمها) ───────── */
|
||||
|
||||
async context(actor: Actor): Promise<RoutineContextDto> {
|
||||
const scope = await this.scope(actor);
|
||||
const ids = scope.isManager ? scope.manageableUserIds : scope.viewableUserIds;
|
||||
const [members, teams, categories] = await Promise.all([
|
||||
this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
user: { isActive: true },
|
||||
...(ids ? { userId: { in: ids } } : {}),
|
||||
},
|
||||
select: { user: { select: { id: true, fullName: true } } },
|
||||
orderBy: { user: { fullName: 'asc' } },
|
||||
}),
|
||||
scope.isManager
|
||||
? this.prisma.team.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(elevated(actor.role)
|
||||
? {}
|
||||
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
this.categories(actor.organizationId),
|
||||
]);
|
||||
return {
|
||||
isManager: scope.isManager,
|
||||
users: members.map((m) => m.user),
|
||||
teams,
|
||||
categories,
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────── روتینها ───────── */
|
||||
|
||||
async list(actor: Actor, query: ListRoutinesQueryDto): Promise<RoutineDto[]> {
|
||||
const scope = await this.scope(actor);
|
||||
const where: Prisma.RoutineWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (query.scope === 'all') {
|
||||
assertManager(scope);
|
||||
if (scope.manageableUserIds) {
|
||||
where.OR = [
|
||||
{ assigneeId: { in: scope.manageableUserIds } },
|
||||
{ team: { members: { some: { userId: actor.userId, role: 'manager' } } } },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
where.OR = [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assigneeId: null, team: { members: { some: { userId: actor.userId } } } },
|
||||
];
|
||||
where.isActive = true;
|
||||
}
|
||||
if (query.q?.trim()) {
|
||||
where.title = { contains: query.q.trim(), mode: 'insensitive' };
|
||||
}
|
||||
const rows = await this.prisma.routine.findMany({
|
||||
where,
|
||||
include: routineInclude,
|
||||
orderBy: [{ isActive: 'desc' }, { timeOfDay: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
const execution = await this.executionMap(
|
||||
rows.map((r) => r.id),
|
||||
query.scope === 'all' ? undefined : actor.userId,
|
||||
);
|
||||
return rows.map((r) => toRoutineDto(r, execution.get(r.id)));
|
||||
}
|
||||
|
||||
async get(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
const row = await this.findRoutine(actor.organizationId, id);
|
||||
const isOwnRoutine = row.assigneeId === actor.userId;
|
||||
if (!isOwnRoutine) assertManager(scope);
|
||||
const execution = await this.executionMap([row.id]);
|
||||
return toRoutineDto(row, execution.get(row.id));
|
||||
}
|
||||
|
||||
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...data,
|
||||
organizationId: actor.organizationId,
|
||||
createdById: actor.userId,
|
||||
} as Prisma.RoutineUncheckedCreateInput,
|
||||
include: routineInclude,
|
||||
});
|
||||
await this.generator.generateForDay(tehranDayKey(), { routineId: row.id });
|
||||
return toRoutineDto(row);
|
||||
}
|
||||
|
||||
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoutineUncheckedUpdateInput,
|
||||
include: routineInclude,
|
||||
});
|
||||
await this.syncTodayTasks(row.id);
|
||||
const execution = await this.executionMap([row.id]);
|
||||
return toRoutineDto(row, execution.get(row.id));
|
||||
}
|
||||
|
||||
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
include: routineInclude,
|
||||
});
|
||||
await this.syncTodayTasks(row.id);
|
||||
const execution = await this.executionMap([row.id]);
|
||||
return toRoutineDto(row, execution.get(row.id));
|
||||
}
|
||||
|
||||
async changeAssignee(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
dto: ChangeRoutineAssigneeDto,
|
||||
): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: target,
|
||||
include: routineInclude,
|
||||
});
|
||||
await this.generator.generateForDay(tehranDayKey(), { routineId: id });
|
||||
const execution = await this.executionMap([row.id]);
|
||||
return toRoutineDto(row, execution.get(row.id));
|
||||
}
|
||||
|
||||
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const src = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, src);
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
updatedAt: _u,
|
||||
category: _cat,
|
||||
assignee: _a,
|
||||
team: _t,
|
||||
deletedAt: _d,
|
||||
...rest
|
||||
} = src;
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...rest,
|
||||
title: `${src.title} (کپی)`.slice(0, 200),
|
||||
isActive: false,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
include: routineInclude,
|
||||
});
|
||||
return toRoutineDto(row);
|
||||
}
|
||||
|
||||
async remove(actor: Actor, id: string) {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
});
|
||||
await this.cancelTodayPending(id, 'حذف روتین');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* ───────── کمکی ───────── */
|
||||
|
||||
/** درصد اجرای روتین در ۳۰ روز اخیر (Taskهای در انتظار امروز حساب نمیشوند) */
|
||||
async executionMap(
|
||||
routineIds: string[],
|
||||
assigneeId?: string,
|
||||
): Promise<Map<string, { total: number; done: number }>> {
|
||||
const map = new Map<string, { total: number; done: number }>();
|
||||
if (!routineIds.length) return map;
|
||||
const today = tehranDayKey();
|
||||
const groups = await this.prisma.routineTask.groupBy({
|
||||
by: ['routineId', 'status'],
|
||||
where: {
|
||||
routineId: { in: routineIds },
|
||||
scheduledFor: { gte: dayKeyToDate(addDays(today, -29)), lte: dayKeyToDate(today) },
|
||||
status: { in: ['completed', 'overdue', 'skipped'] },
|
||||
...(assigneeId ? { assigneeId } : {}),
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const g of groups) {
|
||||
if (!g.routineId) continue;
|
||||
const cur = map.get(g.routineId) ?? { total: 0, done: 0 };
|
||||
cur.total += g._count._all;
|
||||
if (g.status === 'completed') cur.done += g._count._all;
|
||||
map.set(g.routineId, cur);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private async findRoutine(organizationId: string, id: string) {
|
||||
const row = await this.prisma.routine.findFirst({
|
||||
where: { id, organizationId, deletedAt: null },
|
||||
include: routineInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('روتین پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private assertCanManageRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
) {
|
||||
if (scope.manageableUserIds === null) return;
|
||||
if (row.assigneeId && canManage(scope, row.assigneeId)) return;
|
||||
if (row.createdById === scope.userId) return;
|
||||
throw new ForbiddenException('اجازه مدیریت این روتین را ندارید');
|
||||
}
|
||||
|
||||
private async resolveTarget(
|
||||
actor: Actor,
|
||||
scope: RoutineScope,
|
||||
assigneeId?: string | null,
|
||||
teamId?: string | null,
|
||||
): Promise<{ assigneeId: string | null; teamId: string | null }> {
|
||||
if (assigneeId) {
|
||||
if (!canManage(scope, assigneeId)) {
|
||||
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
|
||||
}
|
||||
const member = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: actor.organizationId, userId: assigneeId, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!member) throw new BadRequestException('مسئول انتخابشده عضو سازمان نیست');
|
||||
return { assigneeId, teamId: null };
|
||||
}
|
||||
if (teamId) {
|
||||
const team = await this.prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
organizationId: actor.organizationId,
|
||||
...(elevated(actor.role)
|
||||
? {}
|
||||
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!team) throw new BadRequestException('تیم انتخابشده معتبر نیست');
|
||||
return { assigneeId: null, teamId };
|
||||
}
|
||||
throw new BadRequestException('مسئول یا تیم روتین را انتخاب کنید');
|
||||
}
|
||||
|
||||
private async buildData(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto) {
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
if (dto.categoryId) await this.findCategory(actor.organizationId, dto.categoryId);
|
||||
if (dto.endDate && dto.endDate < dto.startDate) {
|
||||
throw new BadRequestException('تاریخ پایان نباید قبل از تاریخ شروع باشد');
|
||||
}
|
||||
const weekdays = [...new Set(dto.weekdays ?? [])].sort((a, b) => a - b);
|
||||
const specificDates = [...new Set(dto.specificDates ?? [])].sort();
|
||||
if (['weekdays', 'biweekly'].includes(dto.recurrence) && !weekdays.length) {
|
||||
throw new BadRequestException('حداقل یک روز هفته را انتخاب کنید');
|
||||
}
|
||||
if (dto.recurrence === 'every_n_days' && !dto.intervalDays) {
|
||||
throw new BadRequestException('فاصله تکرار (روز) را وارد کنید');
|
||||
}
|
||||
if (dto.recurrence === 'specific_dates' && !specificDates.length) {
|
||||
throw new BadRequestException('حداقل یک تاریخ انتخاب کنید');
|
||||
}
|
||||
return {
|
||||
title: dto.title.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
...target,
|
||||
categoryId: dto.categoryId || null,
|
||||
priority: dto.priority ?? 'medium',
|
||||
timeOfDay: dto.timeOfDay,
|
||||
durationMinutes: dto.durationMinutes ?? null,
|
||||
startDate: dayKeyToDate(dto.startDate),
|
||||
endDate: dto.endDate ? dayKeyToDate(dto.endDate) : null,
|
||||
recurrence: dto.recurrence,
|
||||
weekdays,
|
||||
intervalDays: dto.recurrence === 'every_n_days' ? dto.intervalDays ?? 1 : null,
|
||||
monthDay: dto.recurrence === 'monthly' ? dto.monthDay ?? null : null,
|
||||
specificDates: dto.recurrence === 'specific_dates' ? specificDates.map(dayKeyToDate) : [],
|
||||
isMandatory: dto.isMandatory ?? true,
|
||||
allowSkip: dto.allowSkip ?? false,
|
||||
skipReasonRequired: dto.skipReasonRequired ?? true,
|
||||
reportRequired: dto.reportRequired ?? false,
|
||||
allowAfterDeadline: dto.allowAfterDeadline ?? true,
|
||||
carryOverOverdue: dto.carryOverOverdue ?? false,
|
||||
entityLink: dto.entityLink ?? 'none',
|
||||
reminderMinutesBefore: dto.reminderMinutesBefore ?? null,
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/** همگام کردن Task امروزِ هنوز شروعنشده با تغییرات روتین */
|
||||
private async syncTodayTasks(routineId: string) {
|
||||
const routine = await this.prisma.routine.findUnique({ where: { id: routineId } });
|
||||
if (!routine) return;
|
||||
if (!routine.isActive || routine.deletedAt) {
|
||||
await this.cancelTodayPending(routineId, 'غیرفعال شدن روتین');
|
||||
return;
|
||||
}
|
||||
const today = tehranDayKey();
|
||||
const pending = await this.prisma.routineTask.findMany({
|
||||
where: { routineId, scheduledFor: dayKeyToDate(today), status: 'todo' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (pending.length) {
|
||||
const dueAt = tehranDateTime(today, routine.timeOfDay);
|
||||
await this.prisma.routineTask.updateMany({
|
||||
where: { id: { in: pending.map((p) => p.id) } },
|
||||
data: {
|
||||
title: routine.title,
|
||||
description: routine.description,
|
||||
categoryId: routine.categoryId,
|
||||
priority: routine.priority,
|
||||
isMandatory: routine.isMandatory,
|
||||
dueAt,
|
||||
...(routine.reminderMinutesBefore != null
|
||||
? {
|
||||
reminderAt: new Date(dueAt.getTime() - routine.reminderMinutesBefore * 60_000),
|
||||
reminderSentAt: null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.generator.generateForDay(today, { routineId });
|
||||
}
|
||||
|
||||
private async cancelTodayPending(routineId: string, note: string) {
|
||||
const rows = await this.prisma.routineTask.findMany({
|
||||
where: {
|
||||
routineId,
|
||||
scheduledFor: dayKeyToDate(tehranDayKey()),
|
||||
status: { in: ['todo', 'in_progress'] },
|
||||
},
|
||||
select: { id: true, organizationId: true, status: true },
|
||||
});
|
||||
if (!rows.length) return;
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.routineTask.updateMany({
|
||||
where: { id: { in: rows.map((r) => r.id) } },
|
||||
data: { status: 'cancelled' },
|
||||
}),
|
||||
this.prisma.routineTaskEvent.createMany({
|
||||
data: rows.map((r) => ({
|
||||
organizationId: r.organizationId,
|
||||
taskId: r.id,
|
||||
type: 'status_changed',
|
||||
fromStatus: r.status,
|
||||
toStatus: 'cancelled' as const,
|
||||
note,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user