import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { ActivityDto, ActivityFilter, ActivitySummaryDto, CreateActivityRequest, PaginatedResponse, UpdateActivityRequest, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope'; type ActivityActor = Pick; type ActivityRow = { id: string; type: ActivityDto['type']; status: ActivityDto['status']; priority: ActivityDto['priority']; title: string; description: string | null; dueAt: Date | null; durationMinutes: number | null; reminderAt: Date | null; completedAt: Date | null; createdAt: Date; updatedAt: Date; person: { id: string; firstName: string; lastName: string } | null; company: { id: string; name: string } | null; deal: { id: string; title: string } | null; assignee: { id: string; fullName: string } | null; createdBy: { id: string; fullName: string } | null; }; const include = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, assignee: { select: { id: true, fullName: true } }, createdBy: { select: { id: true, fullName: true } }, } satisfies Prisma.ActivityInclude; @Injectable() export class ActivitiesService { constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, ) {} async summary( organizationId: string, actor: ActivityActor, ): Promise { const { start, end } = dayBounds(); const scope = assigneeWhere(actor); const [today, overdue, upcoming] = await Promise.all([ this.prisma.activity.findMany({ where: { organizationId, ...scope, status: 'open', dueAt: { gte: start, lt: end }, }, include, orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], take: 20, }), this.prisma.activity.findMany({ where: { organizationId, ...scope, status: 'open', dueAt: { lt: start }, }, include, orderBy: [{ dueAt: 'asc' }], take: 20, }), this.prisma.activity.findMany({ where: { organizationId, ...scope, status: 'open', OR: [{ dueAt: { gte: end } }, { dueAt: null }], }, include, orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], take: 10, }), ]); return { today: today.map((row) => this.toDto(row)), overdue: overdue.map((row) => this.toDto(row)), upcoming: upcoming.map((row) => this.toDto(row)), todayCount: today.length, overdueCount: overdue.length, }; } async list( organizationId: string, actor: ActivityActor, filter: ActivityFilter = 'today', q?: string, page = 1, limit = 50, entity?: { personId?: string; companyId?: string; dealId?: string; }, range?: { from?: string; to?: string; }, ): Promise> { const where = this.buildWhere( organizationId, actor, filter, q, entity, range, ); const take = Math.min(Math.max(limit, 1), 200); const [rows, total] = await this.prisma.$transaction([ this.prisma.activity.findMany({ where, include, orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * take, take, }), this.prisma.activity.count({ where }), ]); return { items: rows.map((row) => this.toDto(row)), total, page, limit: take, }; } async get( organizationId: string, id: string, actor: ActivityActor, ): Promise { const row = await this.prisma.activity.findFirst({ where: { id, organizationId, ...assigneeWhere(actor) }, include, }); if (!row) throw new NotFoundException('فعالیت پیدا نشد'); return this.toDto(row); } async create( organizationId: string, userId: string, dto: CreateActivityRequest, actor?: ActivityActor, ): Promise { const linkActor = actor ?? { userId, role: 'owner' }; await this.assertLinks(organizationId, linkActor, dto); const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId; if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); const row = await this.prisma.activity.create({ data: { organizationId, title: dto.title.trim(), type: dto.type ?? 'task', priority: dto.priority ?? 'medium', description: emptyToNull(dto.description), dueAt: dto.dueAt ? new Date(dto.dueAt) : null, durationMinutes: dto.durationMinutes ?? null, reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null, personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), dealId: emptyToNull(dto.dealId ?? null), assigneeId, createdById: userId, }, include, }); if (assigneeId) { await this.notifications.create({ organizationId, userId: assigneeId, type: 'activity_assigned', title: 'فعالیت جدید برای شما', body: row.title, link: `/activities/${row.id}`, entityType: 'activity', entityId: row.id, }); } return this.toDto(row); } async update( organizationId: string, id: string, actor: ActivityActor, dto: UpdateActivityRequest, ): Promise { await this.ensureExists(organizationId, id, actor); await this.assertLinks(organizationId, actor, dto); const data: Prisma.ActivityUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.type !== undefined) data.type = dto.type; if (dto.priority !== undefined) data.priority = dto.priority; if ('description' in dto) data.description = emptyToNull(dto.description); if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null; if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null; if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null; if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('assigneeId' in dto) { const assigneeId = emptyToNull(dto.assigneeId ?? null); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); data.assigneeId = assigneeId; } if (dto.status !== undefined) { data.status = dto.status; data.completedAt = dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined; } const row = await this.prisma.activity.update({ where: { id }, data, include, }); return this.toDto(row); } async complete( organizationId: string, id: string, actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, id, actor); const row = await this.prisma.activity.update({ where: { id }, data: { status: 'done', completedAt: new Date() }, include, }); return this.toDto(row); } async remove( organizationId: string, id: string, actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, id, actor); await this.prisma.activity.delete({ where: { id } }); } private buildWhere( organizationId: string, actor: ActivityActor, filter: ActivityFilter, q?: string, entity?: { personId?: string; companyId?: string; dealId?: string; }, range?: { from?: string; to?: string; }, ): Prisma.ActivityWhereInput { const { start, end } = dayBounds(); const base: Prisma.ActivityWhereInput = { organizationId, ...assigneeWhere(actor), ...(entity?.personId ? { personId: entity.personId } : {}), ...(entity?.companyId ? { companyId: entity.companyId } : {}), ...(entity?.dealId ? { dealId: entity.dealId } : {}), ...(q?.trim() ? { OR: [ { title: { contains: q.trim(), mode: 'insensitive' } }, { description: { contains: q.trim(), mode: 'insensitive' } }, { person: { OR: [ { firstName: { contains: q.trim(), mode: 'insensitive' } }, { lastName: { contains: q.trim(), mode: 'insensitive' } }, ], }, }, { deal: { title: { contains: q.trim(), mode: 'insensitive' } } }, ], } : {}), }; const fromDate = range?.from ? new Date(range.from) : null; const toDate = range?.to ? new Date(range.to) : null; if (fromDate || toDate) { return { ...base, dueAt: { ...(fromDate && !Number.isNaN(fromDate.getTime()) ? { gte: fromDate } : {}), ...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}), }, }; } switch (filter) { case 'today': return { ...base, status: 'open', dueAt: { gte: start, lt: end } }; case 'overdue': return { ...base, status: 'open', dueAt: { lt: start } }; case 'upcoming': return { ...base, status: 'open', OR: [{ dueAt: { gte: end } }, { dueAt: null }], }; case 'done': return { ...base, status: 'done' }; case 'all': default: return base; } } private async ensureExists( organizationId: string, id: string, actor: ActivityActor, ) { const found = await this.prisma.activity.findFirst({ where: { id, organizationId, ...assigneeWhere(actor) }, select: { id: true }, }); if (!found) throw new NotFoundException('فعالیت پیدا نشد'); } private async assertUserInOrg(organizationId: string, userId: string) { const user = await this.prisma.user.findFirst({ where: { id: userId, organizationId }, select: { id: true }, }); if (!user) throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست'); } private async assertLinks( organizationId: string, actor: ActivityActor, dto: { personId?: string | null; companyId?: string | null; dealId?: string | null; }, ) { const personId = emptyToNull(dto.personId ?? null); const companyId = emptyToNull(dto.companyId ?? null); const dealId = emptyToNull(dto.dealId ?? null); const scope = ownerWhere(actor); if (personId) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId, ...scope }, select: { id: true }, }); if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست'); } if (companyId) { const company = await this.prisma.company.findFirst({ where: { id: companyId, organizationId, ...scope }, select: { id: true }, }); if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست'); } if (dealId) { const deal = await this.prisma.deal.findFirst({ where: { id: dealId, organizationId, ...scope }, select: { id: true }, }); if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست'); } } private toDto(row: ActivityRow): ActivityDto { return { id: row.id, type: row.type, status: row.status, priority: row.priority, title: row.title, description: row.description, dueAt: row.dueAt?.toISOString() ?? null, durationMinutes: row.durationMinutes, reminderAt: row.reminderAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null, person: row.person ? { id: row.person.id, fullName: `${row.person.firstName} ${row.person.lastName}`.trim(), } : null, company: row.company, deal: row.deal, assignee: row.assignee, createdBy: row.createdBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } } function emptyToNull(value?: string | null): string | null { if (value == null) return null; const trimmed = value.trim(); return trimmed.length ? trimmed : null; } function dayBounds(now = new Date()) { const start = new Date(now); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return { start, end }; }