feat: enhance operations dashboard and reporting controls
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
||||
CreateSensitiveWordDto,
|
||||
DictionariesService,
|
||||
DictionaryStatusDto,
|
||||
ReorderCommonReportFieldsDto,
|
||||
UpdateDrainageFieldDto,
|
||||
} from './dictionaries.service';
|
||||
|
||||
@ApiTags('dictionaries')
|
||||
@@ -128,6 +130,15 @@ export class DictionariesController {
|
||||
return this.dictionaries.createDrainageField(body);
|
||||
}
|
||||
|
||||
@Put('drainage-fields/:id')
|
||||
updateDrainageField(
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateDrainageFieldDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.dictionaries.updateDrainageField(id, body, operatorId);
|
||||
}
|
||||
|
||||
@Delete('drainage-fields/:id')
|
||||
deleteDrainageField(@Param('id') id: string) {
|
||||
return this.dictionaries.deleteDrainageField(id);
|
||||
@@ -168,6 +179,14 @@ export class DictionariesController {
|
||||
return this.dictionaries.createCommonReportField(body);
|
||||
}
|
||||
|
||||
@Put('common-report-fields/order')
|
||||
reorderCommonReportFields(
|
||||
@Body() body: ReorderCommonReportFieldsDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.dictionaries.reorderCommonReportFields(body, operatorId);
|
||||
}
|
||||
|
||||
@Delete('common-report-fields/:id')
|
||||
deleteCommonReportField(@Param('id') id: string) {
|
||||
return this.dictionaries.deleteCommonReportField(id);
|
||||
|
||||
@@ -33,6 +33,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||
},
|
||||
channelReportField: {
|
||||
@@ -46,6 +47,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
@@ -76,6 +78,42 @@ describe('DictionariesService', () => {
|
||||
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
||||
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
||||
});
|
||||
|
||||
it('reorders every common field in one report type transaction and writes an audit trail', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
commonReportField: {
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]),
|
||||
},
|
||||
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(
|
||||
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'),
|
||||
).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]);
|
||||
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: 'common-2' },
|
||||
data: { sortOrder: 10 },
|
||||
});
|
||||
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: 'common-1' },
|
||||
data: { sortOrder: 20 },
|
||||
});
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }),
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
|
||||
tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]);
|
||||
await expect(
|
||||
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }),
|
||||
).resolves.toEqual([{ id: 'common-1' }]);
|
||||
await expect(
|
||||
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }),
|
||||
).rejects.toThrow('排序范围已变化');
|
||||
});
|
||||
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||
@@ -130,6 +168,32 @@ describe('DictionariesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('edits an unreferenced field atomically and protects mapping keys once referenced', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null };
|
||||
prisma.drainageField.findUnique.mockResolvedValue(existing as never);
|
||||
const tx = {
|
||||
drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) },
|
||||
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(service.updateDrainageField('field-1', {
|
||||
code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ',
|
||||
}, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' }));
|
||||
expect(tx.drainageField.update).toHaveBeenCalledWith({
|
||||
where: { id: 'field-1' },
|
||||
data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' },
|
||||
});
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) });
|
||||
|
||||
prisma.channelReportField.count.mockResolvedValue(1);
|
||||
await expect(service.updateDrainageField('field-1', {
|
||||
code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '',
|
||||
})).rejects.toThrow('不能修改字段代码或类型');
|
||||
});
|
||||
|
||||
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
|
||||
|
||||
export interface UpsertDrainageDetectionRuleDto {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ReorderCommonReportFieldsDto {
|
||||
reportType: 'signature' | 'drainage';
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface DictionaryStatusDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
@@ -399,6 +406,58 @@ export class DictionariesService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) {
|
||||
const code = data.code?.trim();
|
||||
const name = data.name?.trim();
|
||||
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
|
||||
throw new BadRequestException('code must contain only Arabic numerals and English letters');
|
||||
}
|
||||
if (!name) throw new BadRequestException('name is required');
|
||||
if (!['string', 'image', 'file'].includes(data.fieldType)) {
|
||||
throw new BadRequestException('fieldType must be string, image or file');
|
||||
}
|
||||
const existing = await this.prisma.drainageField.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('报备字段不存在');
|
||||
const [usageCount, commonUsageCount] = await Promise.all([
|
||||
this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } } }),
|
||||
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
|
||||
]);
|
||||
if ((usageCount > 0 || commonUsageCount > 0) && (code !== existing.code || data.fieldType !== existing.fieldType)) {
|
||||
throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型');
|
||||
}
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.drainageField.update({
|
||||
where: { id },
|
||||
data: {
|
||||
code,
|
||||
name,
|
||||
fieldType: data.fieldType,
|
||||
description: data.description?.trim() || null,
|
||||
},
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'drainage_field.update',
|
||||
resource: 'drainage_field',
|
||||
resourceId: id,
|
||||
detail: {
|
||||
before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description },
|
||||
after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description },
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new ConflictException('字段代码已存在');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDrainageField(id: string) {
|
||||
const [usageCount, commonUsageCount] = await Promise.all([
|
||||
this.prisma.channelReportField.count({
|
||||
@@ -541,6 +600,40 @@ export class DictionariesService {
|
||||
});
|
||||
}
|
||||
|
||||
async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) {
|
||||
if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) {
|
||||
throw new BadRequestException('通用字段排序参数无效');
|
||||
}
|
||||
if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.commonReportField.findMany({
|
||||
where: { reportType: data.reportType, status: 'active' },
|
||||
select: { id: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const existingIds = existing.map((field) => field.id);
|
||||
if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) {
|
||||
throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试');
|
||||
}
|
||||
for (const [index, id] of data.ids.entries()) {
|
||||
await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } });
|
||||
}
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'common_report_field.reorder',
|
||||
resource: 'common_report_field',
|
||||
detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return tx.commonReportField.findMany({
|
||||
where: { reportType: data.reportType, status: 'active' },
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
}
|
||||
|
||||
deleteCommonReportField(id: string) {
|
||||
return this.prisma.commonReportField.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -511,6 +511,11 @@ describe('OperationsService', () => {
|
||||
todaySpendCents: 24000n,
|
||||
balanceCents: 1000000n,
|
||||
creditCents: 50000n,
|
||||
}]).mockResolvedValueOnce([{
|
||||
segmentCount: 20n,
|
||||
deliveredSegmentCount: 18n,
|
||||
billedCents: 360n,
|
||||
costCents: 216n,
|
||||
}]).mockResolvedValueOnce([
|
||||
{ hour: 9, submittedCount: 12n, successCount: 10n },
|
||||
{ hour: 10, submittedCount: 5n, successCount: 4n },
|
||||
@@ -541,7 +546,15 @@ describe('OperationsService', () => {
|
||||
templates: 1,
|
||||
total: 5,
|
||||
},
|
||||
today: expect.objectContaining({ returnedCents: 10 }),
|
||||
today: expect.objectContaining({
|
||||
returnedCents: 10,
|
||||
segmentCount: 20,
|
||||
deliveredSegmentCount: 18,
|
||||
arrivalRate: 90,
|
||||
billedCents: 360,
|
||||
profitCents: 144,
|
||||
profitRate: 40,
|
||||
}),
|
||||
hourlySendTrend: expect.arrayContaining([
|
||||
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
|
||||
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
|
||||
@@ -592,7 +605,11 @@ describe('OperationsService', () => {
|
||||
updatedAt: { gte: expect.any(Date) },
|
||||
},
|
||||
});
|
||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||
const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||
expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment');
|
||||
expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"');
|
||||
expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"');
|
||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string };
|
||||
expect(hourlyTrendQuery.sql).toContain(
|
||||
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ async dashboard(query: { tenantId?: string }) {
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
enterpriseSpendRows,
|
||||
todayBusinessMetricsRows,
|
||||
downstreamPendingCount,
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
@@ -117,6 +118,67 @@ async dashboard(query: { tenantId?: string }) {
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
segmentCount: bigint;
|
||||
deliveredSegmentCount: bigint;
|
||||
billedCents: bigint;
|
||||
costCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
WITH segment_metrics AS (
|
||||
SELECT
|
||||
COUNT(segment.id)::bigint AS "segmentCount",
|
||||
COUNT(segment.id) FILTER (WHERE segment."receiptStatus" = 'delivered')::bigint AS "deliveredSegmentCount"
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
JOIN "SmsMessageRecord" message ON message.id = segment."messageRecordId"
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
),
|
||||
message_revenue AS (
|
||||
SELECT COALESCE(SUM(
|
||||
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END
|
||||
), 0)::bigint AS "billedCents"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
),
|
||||
submit_cost AS (
|
||||
SELECT COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0)::bigint AS "costCents"
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS audit_count,
|
||||
COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count
|
||||
FROM "SmsMessageSegmentAudit" audit
|
||||
WHERE audit."submitRecordId" = submit.id
|
||||
) segment_receipts ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) AS delivered
|
||||
) legacy_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
AND message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
)
|
||||
SELECT
|
||||
segment_metrics."segmentCount",
|
||||
segment_metrics."deliveredSegmentCount",
|
||||
message_revenue."billedCents",
|
||||
submit_cost."costCents"
|
||||
FROM segment_metrics, message_revenue, submit_cost
|
||||
`),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'pending' },
|
||||
}),
|
||||
@@ -239,6 +301,12 @@ async dashboard(query: { tenantId?: string }) {
|
||||
`),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const todayBusinessMetrics = todayBusinessMetricsRows[0];
|
||||
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
|
||||
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
|
||||
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
|
||||
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
|
||||
const profitCents = billedCents - costCents;
|
||||
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||
@@ -279,6 +347,12 @@ async dashboard(query: { tenantId?: string }) {
|
||||
spendCents: todayTotals.amountCents,
|
||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
segmentCount,
|
||||
deliveredSegmentCount,
|
||||
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
|
||||
billedCents,
|
||||
profitCents,
|
||||
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
|
||||
},
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
|
||||
@@ -331,7 +331,8 @@ export class ReportChannelExportService {
|
||||
};
|
||||
}
|
||||
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
|
||||
if (!data) throw new BadRequestException('导出参数不能为空');
|
||||
if ((data.reportType ?? 'signature') !== 'signature')
|
||||
throw new BadRequestException('首版仅支持单条签名报备资料导出');
|
||||
const detail = await this.getSingleMaterialDetail(data);
|
||||
|
||||
@@ -214,7 +214,7 @@ export class ReportMaterialsController {
|
||||
@Post('single-export')
|
||||
@RequireRecentAuthentication()
|
||||
async exportSingleMaterial(
|
||||
@Body() body: SingleReportMaterialDto,
|
||||
@Body() body: SingleReportMaterialDto | undefined,
|
||||
@CurrentSessionUserId() operatorId: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { ReportMaterialsService } from './report-materials.service';
|
||||
import { mappedCorePatchValue } from './report-materials.helpers';
|
||||
|
||||
describe('ReportMaterialsService', () => {
|
||||
it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => {
|
||||
const service = new ReportMaterialsService({} as never, {} as never, {} as never);
|
||||
await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空');
|
||||
});
|
||||
|
||||
it('does not clear an existing core field when the import column is unmapped or blank', () => {
|
||||
expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined();
|
||||
expect(
|
||||
|
||||
@@ -121,7 +121,7 @@ export class ReportMaterialsService {
|
||||
return this.channelExport.getSingleMaterialDetail(data);
|
||||
}
|
||||
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
|
||||
return this.channelExport.exportSingleMaterial(data, operatorId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user