feat(activities): split customer column, wider search, custom activity types with type-specific fields
- Table: separate assignee and customer columns; search by assignee and activity type - ActivityTypeDef model with per-type field definitions (text, select, checklist, participants, ...) - Default types incl. meeting subject/participants and task checklist; admins manage types - Participants see activities in their lists and get notified Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFieldValues,
|
||||
ActivityFilter,
|
||||
ActivitySummaryDto,
|
||||
ActivityStageCountsDto,
|
||||
@@ -17,10 +18,22 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
|
||||
import {
|
||||
type Actor,
|
||||
activityAccessWhere,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||
import {
|
||||
ActivityTypesService,
|
||||
type SystemTypes,
|
||||
type TypeRow,
|
||||
} from './activity-types.service';
|
||||
|
||||
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -29,6 +42,7 @@ type StageScope = { stageId: string };
|
||||
|
||||
type ListFacets = {
|
||||
type?: ActivityDto['type'];
|
||||
typeDefId?: string;
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
};
|
||||
@@ -57,10 +71,14 @@ type ActivityRow = {
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
stage?: { id: string; kind: ActivityDto['status'] } | null;
|
||||
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
||||
fields?: Prisma.JsonValue | null;
|
||||
participantIds?: string[];
|
||||
};
|
||||
|
||||
const include = {
|
||||
stage: { select: { id: true, kind: true } },
|
||||
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
@@ -77,14 +95,23 @@ export class ActivitiesService {
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly stages: ActivityStagesService,
|
||||
private readonly types: ActivityTypesService,
|
||||
) {}
|
||||
|
||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||
const [stages, types] = await Promise.all([
|
||||
this.stages.defaults(organizationId),
|
||||
this.types.system(organizationId),
|
||||
]);
|
||||
return { stages, types };
|
||||
}
|
||||
|
||||
async summary(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope = assigneeWhere(actor);
|
||||
const scope = activityAccessWhere(actor);
|
||||
const [todayCount, overdueCount] = await Promise.all([
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
@@ -139,10 +166,11 @@ export class ActivitiesService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const ctx = await this.ctx(organizationId);
|
||||
return {
|
||||
today: today.map((row) => this.toDto(row)),
|
||||
overdue: overdue.map((row) => this.toDto(row)),
|
||||
upcoming: upcoming.map((row) => this.toDto(row)),
|
||||
today: today.map((row) => this.toDto(row, ctx)),
|
||||
overdue: overdue.map((row) => this.toDto(row, ctx)),
|
||||
upcoming: upcoming.map((row) => this.toDto(row, ctx)),
|
||||
todayCount,
|
||||
overdueCount,
|
||||
};
|
||||
@@ -168,6 +196,7 @@ export class ActivitiesService {
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const typeRows = await this.types.rows(organizationId);
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
actor,
|
||||
@@ -178,13 +207,17 @@ export class ActivitiesService {
|
||||
desk,
|
||||
facets,
|
||||
stageScope,
|
||||
typeRows,
|
||||
);
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
||||
);
|
||||
}
|
||||
const defaults = await this.stages.defaults(organizationId);
|
||||
const ctx: DtoContext = {
|
||||
stages: await this.stages.defaults(organizationId),
|
||||
types: this.types.systemOf(typeRows),
|
||||
};
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
@@ -198,7 +231,7 @@ export class ActivitiesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row, defaults)),
|
||||
items: rows.map((row) => this.toDto(row, ctx)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
@@ -227,6 +260,7 @@ export class ActivitiesService {
|
||||
desk,
|
||||
facets,
|
||||
{ stageId: 'all' },
|
||||
await this.types.rows(organizationId),
|
||||
);
|
||||
const groups = await this.prisma.activity.groupBy({
|
||||
by: ['status', 'stageId'],
|
||||
@@ -276,7 +310,7 @@ export class ActivitiesService {
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async get(
|
||||
@@ -285,11 +319,19 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
const row = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
const dto = this.toDto(row, await this.ctx(organizationId));
|
||||
if (row.participantIds.length) {
|
||||
dto.participantUsers = await this.prisma.user.findMany({
|
||||
where: { id: { in: row.participantIds } },
|
||||
select: { id: true, fullName: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
});
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -313,6 +355,15 @@ export class ActivitiesService {
|
||||
const initialStage = initialStageId
|
||||
? await this.stages.get(organizationId, initialStageId)
|
||||
: null;
|
||||
const resolved = await this.types.resolve(organizationId, {
|
||||
typeDefId: dto.typeDefId,
|
||||
type: dto.type,
|
||||
});
|
||||
const { fields, participantIds } = await this.types.sanitizeValues(
|
||||
organizationId,
|
||||
resolved.fields,
|
||||
dto.fields,
|
||||
);
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
@@ -325,7 +376,10 @@ export class ActivitiesService {
|
||||
}
|
||||
: {}),
|
||||
title: dto.title.trim(),
|
||||
type: dto.type ?? 'task',
|
||||
type: resolved.baseType,
|
||||
typeDefId: resolved.typeDefId,
|
||||
fields: fields as Prisma.InputJsonValue,
|
||||
participantIds,
|
||||
priority: dto.priority ?? 'medium',
|
||||
description: emptyToNull(dto.description),
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
|
||||
@@ -358,6 +412,11 @@ export class ActivitiesService {
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
await this.notifyParticipants(
|
||||
organizationId,
|
||||
row,
|
||||
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
|
||||
);
|
||||
|
||||
await this.messageAutomations.onEvent('activity_created', {
|
||||
organizationId,
|
||||
@@ -374,7 +433,7 @@ export class ActivitiesService {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -386,9 +445,33 @@ export class ActivitiesService {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto);
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
|
||||
});
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
let addedParticipants: string[] = [];
|
||||
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
|
||||
if (typeChanged || dto.fields !== undefined) {
|
||||
const resolved = await this.types.resolve(
|
||||
organizationId,
|
||||
typeChanged
|
||||
? { typeDefId: dto.typeDefId, type: dto.type }
|
||||
: { typeDefId: existing?.typeDefId, type: existing?.type },
|
||||
);
|
||||
const { fields, participantIds } = await this.types.sanitizeValues(
|
||||
organizationId,
|
||||
resolved.fields,
|
||||
dto.fields ?? existing?.fields,
|
||||
);
|
||||
data.type = resolved.baseType;
|
||||
data.typeDefId = resolved.typeDefId;
|
||||
data.fields = fields as Prisma.InputJsonValue;
|
||||
data.participantIds = participantIds;
|
||||
const before = new Set(existing?.participantIds ?? []);
|
||||
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
||||
}
|
||||
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;
|
||||
@@ -434,10 +517,6 @@ export class ActivitiesService {
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -448,10 +527,21 @@ export class ActivitiesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (addedParticipants.length) {
|
||||
const assigneeSet = new Set([
|
||||
...(row.assignee ? [row.assignee.id] : []),
|
||||
...row.assignees.map((a) => a.id),
|
||||
]);
|
||||
await this.notifyParticipants(
|
||||
organizationId,
|
||||
row,
|
||||
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
|
||||
);
|
||||
}
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async complete(
|
||||
@@ -472,7 +562,7 @@ export class ActivitiesService {
|
||||
if (existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.stages.defaults(organizationId));
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async remove(
|
||||
@@ -501,6 +591,7 @@ export class ActivitiesService {
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
typeRows: TypeRow[] = [],
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
@@ -509,13 +600,16 @@ export class ActivitiesService {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: assigneeWhere(actor);
|
||||
: activityAccessWhere(actor);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const facetTypeDef = facets?.typeDefId?.trim();
|
||||
const andParts: Prisma.ActivityWhereInput[] = [
|
||||
scope,
|
||||
...(facets?.type ? [{ type: facets.type }] : []),
|
||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||
...(facets?.status ? [{ status: facets.status }] : []),
|
||||
...(facetAssignee
|
||||
? [
|
||||
@@ -547,6 +641,10 @@ export class ActivitiesService {
|
||||
},
|
||||
{ company: { name: ci(t) } },
|
||||
{ deal: { title: ci(t) } },
|
||||
{ assignee: { fullName: ci(t) } },
|
||||
{ assignees: { some: { fullName: ci(t) } } },
|
||||
...typesMatching(t).map((type) => ({ type })),
|
||||
...this.types.searchByName(typeRows, t),
|
||||
]);
|
||||
if (search) andParts.push(search);
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
@@ -633,7 +731,7 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
) {
|
||||
const found = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
@@ -684,9 +782,37 @@ export class ActivitiesService {
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
|
||||
private async notifyParticipants(
|
||||
organizationId: string,
|
||||
row: { id: string; title: string },
|
||||
userIds: string[],
|
||||
) {
|
||||
for (const uid of new Set(userIds)) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: uid,
|
||||
type: 'activity_assigned',
|
||||
title: 'شما بهعنوان شرکتکننده اضافه شدید',
|
||||
body: row.title,
|
||||
link: `/activities/${row.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
|
||||
const fields =
|
||||
row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
|
||||
? (row.fields as unknown as ActivityFieldValues)
|
||||
: {};
|
||||
return {
|
||||
stageId: this.stages.effectiveStageId(row, defaults),
|
||||
stageId: this.stages.effectiveStageId(row, ctx?.stages),
|
||||
typeDef: row.typeDef
|
||||
? this.types.toSummary(row.typeDef)
|
||||
: (ctx?.types?.[row.type] ?? null),
|
||||
fields,
|
||||
participantIds: row.participantIds ?? [],
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
@@ -741,6 +867,22 @@ export class ActivitiesService {
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_SEARCH_TERMS: Record<ActivityDto['type'], string[]> = {
|
||||
call: ['تماس', 'تلفن', 'call'],
|
||||
meeting: ['جلسه', 'ملاقات', 'meeting'],
|
||||
task: ['وظیفه', 'تسک', 'task'],
|
||||
note: ['پیام', 'یادداشت', 'note'],
|
||||
};
|
||||
|
||||
/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
|
||||
function typesMatching(term: string): ActivityDto['type'][] {
|
||||
const t = term.trim().toLowerCase();
|
||||
if (t.length < 2) return [];
|
||||
return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
|
||||
TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
|
||||
);
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
|
||||
Reference in New Issue
Block a user