feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities
This commit is contained in:
@@ -173,6 +173,7 @@ export class ActivitiesController {
|
||||
personId: query.personId,
|
||||
companyId: query.companyId,
|
||||
dealId: query.dealId,
|
||||
leadId: query.leadId,
|
||||
},
|
||||
{
|
||||
from: query.from,
|
||||
|
||||
@@ -69,6 +69,7 @@ type ActivityRow = {
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
lead?: { id: string; title: string } | null;
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
@@ -84,6 +85,7 @@ const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
lead: { select: { id: true, title: true } },
|
||||
assignee: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
@@ -190,6 +192,7 @@ export class ActivitiesService {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
@@ -602,6 +605,7 @@ export class ActivitiesService {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
@@ -643,6 +647,7 @@ export class ActivitiesService {
|
||||
...(entity?.personId ? [{ personId: entity.personId }] : []),
|
||||
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
|
||||
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
|
||||
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
|
||||
];
|
||||
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
|
||||
{ title: ci(t) },
|
||||
@@ -872,6 +877,7 @@ export class ActivitiesService {
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
lead: row.lead ?? null,
|
||||
assignee: row.assignee,
|
||||
assignees: (() => {
|
||||
const list = [...(row.assignees ?? [])];
|
||||
|
||||
@@ -91,6 +91,10 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leadId?: string;
|
||||
|
||||
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export type FormFieldLabel = { key: string; label: string };
|
||||
|
||||
export type TemplateVars = {
|
||||
title: string;
|
||||
mobile?: string | null;
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||
if (!ids.length) return null;
|
||||
const n = Math.max(1, Math.trunc(counter));
|
||||
return ids[(n - 1) % ids.length] ?? null;
|
||||
}
|
||||
|
||||
/** جایگزینی {{title}}، {{mobile}}، {{form}} و {{field.<key>}} */
|
||||
export function renderAutomationTemplate(text: string, vars: TemplateVars): string {
|
||||
return text.replace(/\{\{\s*([\w.\u0600-\u06FF-]+)\s*\}\}/g, (_m, name: string) => {
|
||||
if (name === 'title') return vars.title ?? '';
|
||||
if (name === 'mobile') return vars.mobile ?? '';
|
||||
if (name === 'form') return vars.formName ?? '';
|
||||
if (name.startsWith('field.')) {
|
||||
const v = vars.formValues?.[name.slice(6)];
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFormAnswers(
|
||||
fields: FormFieldLabel[] | null | undefined,
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): string {
|
||||
if (!values) return '';
|
||||
const lines: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const f of fields ?? []) {
|
||||
seen.add(f.key);
|
||||
const raw = values[f.key];
|
||||
const v = raw == null ? '' : String(raw).trim();
|
||||
if (v) lines.push(`${f.label}: ${v}`);
|
||||
}
|
||||
for (const [key, raw] of Object.entries(values)) {
|
||||
if (seen.has(key)) continue;
|
||||
const v = raw == null ? '' : String(raw).trim();
|
||||
if (v) lines.push(`${key}: ${v}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -20,30 +20,42 @@ import {
|
||||
MAX_AUTOMATION_STEPS,
|
||||
automationStepToConfig,
|
||||
firstAutomationAction,
|
||||
isAutomationCondition,
|
||||
matchFormFieldCondition,
|
||||
normalizeAutomationSteps,
|
||||
packAutomationConfig,
|
||||
planAutomationActions,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import {
|
||||
formatFormAnswers,
|
||||
pickRoundRobin,
|
||||
renderAutomationTemplate,
|
||||
type FormFieldLabel,
|
||||
} from './automation-engine.util';
|
||||
|
||||
export type AutomationContext = {
|
||||
organizationId: string;
|
||||
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity';
|
||||
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead';
|
||||
entityId: string;
|
||||
title: string;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
leadId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
/** مثلاً id کمپین SMS برای فیلتر قانون */
|
||||
/** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */
|
||||
sourceId?: string | null;
|
||||
sourceType?: string | null;
|
||||
clickCount?: number | null;
|
||||
mobile?: string | null;
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
formFields?: FormFieldLabel[] | null;
|
||||
};
|
||||
|
||||
type AutomationFacts = {
|
||||
@@ -51,8 +63,11 @@ type AutomationFacts = {
|
||||
hasPerson: boolean;
|
||||
hasCompany: boolean;
|
||||
ticketPriority: string | null;
|
||||
formValues: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type StepRef = { ruleId: string; stepId: string };
|
||||
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
private readonly logger = new Logger(AutomationsService.name);
|
||||
@@ -79,6 +94,7 @@ export class AutomationsService {
|
||||
const steps = resolveSteps(dto.action, dto.config);
|
||||
const action = firstAutomationAction(steps);
|
||||
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
|
||||
await this.assertStepRefs(organizationId, steps);
|
||||
const row = await this.prisma.automationRule.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -86,7 +102,7 @@ export class AutomationsService {
|
||||
trigger: dto.trigger,
|
||||
action,
|
||||
isActive: dto.isActive ?? true,
|
||||
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
||||
config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
@@ -114,6 +130,7 @@ export class AutomationsService {
|
||||
if (steps && !nextAction) {
|
||||
throw new BadRequestException('حداقل یک عمل لازم است');
|
||||
}
|
||||
if (steps) await this.assertStepRefs(organizationId, steps);
|
||||
|
||||
const row = await this.prisma.automationRule.update({
|
||||
where: { id },
|
||||
@@ -124,7 +141,10 @@ export class AutomationsService {
|
||||
...(steps && nextAction
|
||||
? {
|
||||
action: nextAction,
|
||||
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
||||
config: packAutomationConfig(
|
||||
steps,
|
||||
chainExtra(dto.config ?? asConfig(existing.config)),
|
||||
) as Prisma.InputJsonValue,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -151,7 +171,11 @@ export class AutomationsService {
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
const facts = await this.loadFacts(ctx);
|
||||
if (!rules.length) return;
|
||||
const facts: AutomationFacts = {
|
||||
...(await this.loadFacts(ctx)),
|
||||
formValues: ctx.formValues ?? {},
|
||||
};
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
const config = asConfig(rule.config);
|
||||
@@ -167,13 +191,20 @@ export class AutomationsService {
|
||||
rule.action as AutomationAction,
|
||||
config,
|
||||
);
|
||||
for (const step of steps) {
|
||||
if (isAutomationCondition(step)) {
|
||||
if (!matchCondition(step, facts)) break;
|
||||
continue;
|
||||
const actions = planAutomationActions(steps, config.chainMode, (step) =>
|
||||
matchCondition(step, facts),
|
||||
);
|
||||
for (const step of actions) {
|
||||
try {
|
||||
await this.execute(step.action!, automationStepToConfig(step), ctx, {
|
||||
ruleId: rule.id,
|
||||
stepId: step.id,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Automation ${rule.id} step ${step.id} failed: ${(e as Error)?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
if (!step.action) continue;
|
||||
await this.execute(step.action, automationStepToConfig(step), ctx);
|
||||
}
|
||||
} catch {
|
||||
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
|
||||
@@ -181,7 +212,9 @@ export class AutomationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFacts(ctx: AutomationContext): Promise<AutomationFacts> {
|
||||
private async loadFacts(
|
||||
ctx: AutomationContext,
|
||||
): Promise<Omit<AutomationFacts, 'formValues'>> {
|
||||
if (ctx.entityType === 'deal') {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||
@@ -258,42 +291,26 @@ export class AutomationsService {
|
||||
|
||||
private async execute(
|
||||
action: AutomationAction,
|
||||
config: AutomationRuleConfig,
|
||||
rawConfig: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
ref: StepRef,
|
||||
) {
|
||||
const vars = {
|
||||
title: ctx.title,
|
||||
mobile: ctx.mobile,
|
||||
formName: ctx.formName,
|
||||
formValues: ctx.formValues,
|
||||
};
|
||||
const config: AutomationRuleConfig = {
|
||||
...rawConfig,
|
||||
title: rawConfig.title ? renderAutomationTemplate(rawConfig.title, vars) : rawConfig.title,
|
||||
body: rawConfig.body ? renderAutomationTemplate(rawConfig.body, vars) : rawConfig.body,
|
||||
};
|
||||
const title =
|
||||
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
|
||||
|
||||
if (action === 'create_activity') {
|
||||
const assigneeId =
|
||||
config.assigneeId?.trim() ||
|
||||
ctx.assigneeId ||
|
||||
ctx.ownerId ||
|
||||
null;
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
type: (config.activityType as never) || 'task',
|
||||
title,
|
||||
description: config.body?.trim() || null,
|
||||
personId: ctx.personId ?? null,
|
||||
companyId: ctx.companyId ?? null,
|
||||
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
|
||||
assigneeId,
|
||||
},
|
||||
});
|
||||
if (assigneeId) {
|
||||
await this.notifications.create({
|
||||
organizationId: ctx.organizationId,
|
||||
userId: assigneeId,
|
||||
type: 'automation',
|
||||
title: 'فعالیت خودکار',
|
||||
body: title,
|
||||
link: entityLink(ctx),
|
||||
entityType: ctx.entityType,
|
||||
entityId: ctx.entityId,
|
||||
});
|
||||
}
|
||||
await this.executeCreateActivity(title, config, ctx, ref);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,6 +330,163 @@ export class AutomationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeCreateActivity(
|
||||
title: string,
|
||||
config: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
ref: StepRef,
|
||||
) {
|
||||
const assigneeId = await this.resolveAssignee(config, ctx, ref);
|
||||
|
||||
let type = (config.activityType as 'call' | 'meeting' | 'task' | 'note') || 'task';
|
||||
let typeDefId: string | null = null;
|
||||
if (config.activityTypeDefId) {
|
||||
const def = await this.prisma.activityTypeDef.findFirst({
|
||||
where: { id: config.activityTypeDefId, organizationId: ctx.organizationId },
|
||||
select: { id: true, baseType: true },
|
||||
});
|
||||
if (def) {
|
||||
typeDefId = def.id;
|
||||
type = def.baseType as typeof type;
|
||||
}
|
||||
}
|
||||
|
||||
const offset = config.dueOffsetMinutes;
|
||||
const dueAt =
|
||||
typeof offset === 'number' && Number.isFinite(offset)
|
||||
? new Date(Date.now() + Math.max(0, offset) * 60_000)
|
||||
: null;
|
||||
|
||||
const description = [
|
||||
config.body?.trim(),
|
||||
leadSummary(ctx),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.slice(0, 5000);
|
||||
|
||||
const activity = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
type,
|
||||
typeDefId,
|
||||
title,
|
||||
description: description || null,
|
||||
dueAt,
|
||||
personId: ctx.personId ?? null,
|
||||
companyId: ctx.companyId ?? null,
|
||||
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
|
||||
leadId: ctx.leadId ?? (ctx.entityType === 'lead' ? ctx.entityId : null),
|
||||
assigneeId,
|
||||
assignees: assigneeId ? { connect: [{ id: assigneeId }] } : undefined,
|
||||
tags: ['automation'],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (assigneeId) {
|
||||
await this.notifications.create({
|
||||
organizationId: ctx.organizationId,
|
||||
userId: assigneeId,
|
||||
type: 'automation',
|
||||
title: 'فعالیت خودکار',
|
||||
body: title,
|
||||
link: `/activities/${activity.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: activity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAssignee(
|
||||
config: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
ref: StepRef,
|
||||
): Promise<string | null> {
|
||||
const fallback = ctx.assigneeId || ctx.ownerId || null;
|
||||
const mode = config.assignMode ?? (config.assigneeId ? 'user' : 'owner');
|
||||
|
||||
if (mode === 'user') {
|
||||
const id = config.assigneeId?.trim();
|
||||
if (id && (await this.isMember(ctx.organizationId, id))) return id;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (mode === 'team_round_robin' || mode === 'users_round_robin') {
|
||||
let candidates: string[] = [];
|
||||
if (mode === 'team_round_robin' && config.assigneeTeamId) {
|
||||
const members = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: config.assigneeTeamId,
|
||||
team: { organizationId: ctx.organizationId },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
candidates = members.map((m) => m.userId);
|
||||
} else if (mode === 'users_round_robin') {
|
||||
candidates = config.assigneeUserIds ?? [];
|
||||
}
|
||||
const valid = await this.filterMembers(ctx.organizationId, candidates);
|
||||
if (!valid.length) return fallback;
|
||||
const cursor = await this.prisma.automationCursor.upsert({
|
||||
where: { ruleId_stepId: { ruleId: ref.ruleId, stepId: ref.stepId } },
|
||||
create: { ruleId: ref.ruleId, stepId: ref.stepId, counter: 1 },
|
||||
update: { counter: { increment: 1 } },
|
||||
select: { counter: true },
|
||||
});
|
||||
return pickRoundRobin(valid, cursor.counter) ?? fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async isMember(organizationId: string, userId: string) {
|
||||
return (await this.filterMembers(organizationId, [userId])).length > 0;
|
||||
}
|
||||
|
||||
private async filterMembers(organizationId: string, userIds: string[]) {
|
||||
const ids = [...new Set(userIds.filter(Boolean))];
|
||||
if (!ids.length) return [];
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
memberships: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
private async assertStepRefs(organizationId: string, steps: AutomationStep[]) {
|
||||
for (const step of steps) {
|
||||
if (step.kind === 'condition' || step.action !== 'create_activity') continue;
|
||||
if (step.assignMode === 'team_round_robin') {
|
||||
if (!step.assigneeTeamId) {
|
||||
throw new BadRequestException('برای تقسیم نوبتی، تیم را انتخاب کنید');
|
||||
}
|
||||
const team = await this.prisma.team.findFirst({
|
||||
where: { id: step.assigneeTeamId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!team) throw new BadRequestException('تیم انتخابشده معتبر نیست');
|
||||
}
|
||||
if (step.assignMode === 'users_round_robin') {
|
||||
const ids = step.assigneeUserIds ?? [];
|
||||
if (!ids.length) {
|
||||
throw new BadRequestException('برای تقسیم نوبتی، حداقل یک کاربر انتخاب کنید');
|
||||
}
|
||||
const valid = await this.filterMembers(organizationId, ids);
|
||||
if (valid.length !== new Set(ids).size) {
|
||||
throw new BadRequestException('یکی از کاربران انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
if (step.assignMode === 'user' && step.assigneeId) {
|
||||
if (!(await this.isMember(organizationId, step.assigneeId))) {
|
||||
throw new BadRequestException('مسئول انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeNotify(
|
||||
title: string,
|
||||
config: AutomationRuleConfig,
|
||||
@@ -464,9 +638,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
|
||||
return current === value;
|
||||
}
|
||||
|
||||
if (field === 'form_field') {
|
||||
const key = step.conditionKey?.trim();
|
||||
if (!key) return false;
|
||||
return matchFormFieldCondition(op, facts.formValues[key], step.conditionValue);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function chainExtra(
|
||||
config?: AutomationRuleConfig | null,
|
||||
): Pick<AutomationRuleConfig, 'sourceId' | 'chainMode'> {
|
||||
return {
|
||||
sourceId: config?.sourceId ?? null,
|
||||
chainMode: config?.chainMode === 'branches' ? 'branches' : config?.chainMode === 'stop' ? 'stop' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function leadSummary(ctx: AutomationContext): string {
|
||||
if (ctx.sourceType !== 'lead_form') return '';
|
||||
const head = [
|
||||
ctx.formName ? `فرم: ${ctx.formName}` : '',
|
||||
ctx.title ? `نام: ${ctx.title}` : '',
|
||||
ctx.mobile ? `موبایل: ${ctx.mobile}` : '',
|
||||
].filter(Boolean);
|
||||
const answers = formatFormAnswers(ctx.formFields, ctx.formValues);
|
||||
return [head.join('\n'), answers ? `پاسخهای فرم:\n${answers}` : '']
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function defaultTitle(action: AutomationAction, entityTitle: string) {
|
||||
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
|
||||
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
||||
@@ -479,5 +681,6 @@ function entityLink(ctx: AutomationContext) {
|
||||
if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`;
|
||||
return '/';
|
||||
}
|
||||
|
||||
@@ -385,7 +385,19 @@ export class LeadFormsService {
|
||||
personId,
|
||||
ownerId: ownerId ?? actorId,
|
||||
};
|
||||
await this.automations.runTrigger('lead_submitted', leadCtx);
|
||||
await this.automations.runTrigger('lead_submitted', {
|
||||
...leadCtx,
|
||||
entityType: personId ? 'person' : leadId ? 'lead' : 'person',
|
||||
sourceId: form.id,
|
||||
sourceType: 'lead_form',
|
||||
leadId,
|
||||
mobile: mapped.mobile ?? mapped.phone ?? null,
|
||||
formName: form.name,
|
||||
formValues: values,
|
||||
formFields: form.fields
|
||||
.filter((f) => f.fieldType !== 'heading')
|
||||
.map((f) => ({ key: f.key, label: f.label })),
|
||||
});
|
||||
await this.messageAutomations.onEvent('lead_submitted', leadCtx);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user