feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属
- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id - lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案 - wecom-client 新增 appchat create/update/get/send 封装 - OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份 - 外部 OAuth 领取人 upsert 打 partner_type='group_owner' - portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主 - 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态 - 催办与超期升级同步播报进任务群
This commit is contained in:
@@ -5,6 +5,7 @@ export type ProjectRow = {
|
||||
name: string;
|
||||
brand: string;
|
||||
main_group_member_ids: string;
|
||||
owner_userid: string;
|
||||
status: string;
|
||||
note: string;
|
||||
created_at: string;
|
||||
@@ -18,6 +19,7 @@ export type ProjectSummary = {
|
||||
note: string;
|
||||
createdAt: string;
|
||||
mainGroupMemberIds: string[];
|
||||
ownerUserid: string;
|
||||
taskCount: number;
|
||||
groupChatCount: number;
|
||||
};
|
||||
@@ -95,7 +97,7 @@ function parseJsonArray(value: string): string[] {
|
||||
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.status, p.note, p.created_at,
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count,
|
||||
(SELECT COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
|
||||
FROM projects p
|
||||
@@ -110,6 +112,7 @@ export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]
|
||||
note: row.note,
|
||||
createdAt: row.created_at,
|
||||
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
|
||||
ownerUserid: row.owner_userid ?? "",
|
||||
taskCount: Number(row.task_count),
|
||||
groupChatCount: Number(row.group_chat_count),
|
||||
}));
|
||||
@@ -122,7 +125,7 @@ export async function getProjectDetail(
|
||||
const id = text(projectId);
|
||||
const project = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, main_group_member_ids, status, note, created_at
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, status, note, created_at
|
||||
FROM projects WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -170,6 +173,7 @@ export async function getProjectDetail(
|
||||
note: project.note,
|
||||
createdAt: project.created_at,
|
||||
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
|
||||
ownerUserid: project.owner_userid ?? "",
|
||||
taskCount: tasks.results.length,
|
||||
groupChatCount: chats.results.length,
|
||||
},
|
||||
@@ -192,6 +196,7 @@ export type SaveProjectInput = {
|
||||
name?: unknown;
|
||||
brand?: unknown;
|
||||
mainGroupMemberIds?: unknown;
|
||||
ownerUserid?: unknown;
|
||||
note?: unknown;
|
||||
};
|
||||
|
||||
@@ -204,6 +209,7 @@ export async function saveProject(
|
||||
const brand = text(input.brand);
|
||||
const note = text(input.note ?? "");
|
||||
const memberIds = parseMemberIds(input.mainGroupMemberIds);
|
||||
const ownerUserid = text(input.ownerUserid).slice(0, 64);
|
||||
if (!name) throw new ProjectError("请填写项目名称");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -212,10 +218,10 @@ export async function saveProject(
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE projects
|
||||
SET name = ?, brand = ?, main_group_member_ids = ?, note = ?
|
||||
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(name, brand, JSON.stringify(memberIds), note, id)
|
||||
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
|
||||
.run();
|
||||
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
||||
} else {
|
||||
@@ -223,14 +229,15 @@ export async function saveProject(
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO projects
|
||||
(id, name, brand, main_group_member_ids, status, note, created_at)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
|
||||
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
projectId,
|
||||
name,
|
||||
brand,
|
||||
JSON.stringify(memberIds),
|
||||
ownerUserid,
|
||||
note,
|
||||
now,
|
||||
)
|
||||
|
||||
158
lib/task-group-service.ts
Normal file
158
lib/task-group-service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { DatabaseClient } from "./database";
|
||||
import {
|
||||
createAppChat,
|
||||
getAppChat,
|
||||
hasAppCredentials,
|
||||
sendAppChatMessage,
|
||||
updateAppChat,
|
||||
type WecomConfig,
|
||||
} from "./wecom-client";
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
export type TaskChatEnsureResult = {
|
||||
chatId: string | null;
|
||||
created: boolean;
|
||||
reason?: "no_task" | "no_owner" | "not_enough_members" | "no_credentials";
|
||||
};
|
||||
|
||||
type TaskChatRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
task_chat_id: string | null;
|
||||
owner_userid: string | null;
|
||||
};
|
||||
|
||||
function text(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function taskChatId(taskId: string) {
|
||||
return `tgc_${text(taskId)}`;
|
||||
}
|
||||
|
||||
function chatNameForTask(taskName: string) {
|
||||
return `【任务】${text(taskName)}`.slice(0, 20);
|
||||
}
|
||||
|
||||
async function syncMembers(
|
||||
config: WecomConfig,
|
||||
chatid: string,
|
||||
members: string[],
|
||||
fetchImpl: FetchLike,
|
||||
) {
|
||||
const info = await getAppChat(config, chatid, fetchImpl);
|
||||
if (!info) return;
|
||||
const existing = new Set(info.userlist);
|
||||
const missing = members.filter((userid) => !existing.has(userid));
|
||||
if (missing.length > 0) {
|
||||
await updateAppChat(config, { chatid, addUserList: missing }, fetchImpl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureTaskChatForMembers(
|
||||
db: DatabaseClient,
|
||||
params: { taskId: string; addUserids?: string[] },
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<TaskChatEnsureResult> {
|
||||
const taskId = text(params.taskId);
|
||||
if (!taskId) return { chatId: null, created: false, reason: "no_task" };
|
||||
if (!hasAppCredentials(config)) {
|
||||
return { chatId: null, created: false, reason: "no_credentials" };
|
||||
}
|
||||
const task = await db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, t.task_chat_id, p.owner_userid AS owner_userid
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.id = ?`,
|
||||
)
|
||||
.bind(taskId)
|
||||
.first<TaskChatRow>();
|
||||
if (!task) return { chatId: null, created: false, reason: "no_task" };
|
||||
const ownerUserid = text(task.owner_userid);
|
||||
if (!ownerUserid) {
|
||||
return { chatId: null, created: false, reason: "no_owner" };
|
||||
}
|
||||
const members = [
|
||||
...new Set(
|
||||
[ownerUserid, ...(params.addUserids ?? []).map((id) => text(id))].filter(
|
||||
Boolean,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
let chatId = text(task.task_chat_id);
|
||||
let createdNow = false;
|
||||
if (!chatId) {
|
||||
chatId = taskChatId(taskId);
|
||||
const existing = await getAppChat(config, chatId, fetchImpl);
|
||||
if (!existing) {
|
||||
if (members.length < 2) {
|
||||
return { chatId: null, created: false, reason: "not_enough_members" };
|
||||
}
|
||||
const created = await createAppChat(
|
||||
config,
|
||||
{ name: chatNameForTask(task.name), userlist: members, chatid: chatId },
|
||||
fetchImpl,
|
||||
);
|
||||
chatId = created.chatid;
|
||||
createdNow = true;
|
||||
}
|
||||
await db
|
||||
.prepare("UPDATE tasks SET task_chat_id = ? WHERE id = ?")
|
||||
.bind(chatId, taskId)
|
||||
.run();
|
||||
}
|
||||
|
||||
await syncMembers(config, chatId, members, fetchImpl);
|
||||
return { chatId, created: createdNow };
|
||||
}
|
||||
|
||||
export async function sendTaskChatMessage(
|
||||
db: DatabaseClient,
|
||||
taskId: string,
|
||||
message: string,
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const content = text(message);
|
||||
if (!content) return false;
|
||||
const row = await db
|
||||
.prepare("SELECT task_chat_id FROM tasks WHERE id = ?")
|
||||
.bind(text(taskId))
|
||||
.first<{ task_chat_id: string | null }>();
|
||||
const chatId = text(row?.task_chat_id);
|
||||
if (!chatId) return false;
|
||||
await sendAppChatMessage(config, { chatid: chatId, text: content }, fetchImpl);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[KOC LOOP] 任务群消息发送失败(task=${taskId})`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function taskClaimBroadcastText(
|
||||
taskName: string,
|
||||
claimantName: string,
|
||||
quantity: number,
|
||||
) {
|
||||
return `【领取】${text(claimantName)} 认领 ${quantity} 份 · ${text(taskName)}`;
|
||||
}
|
||||
|
||||
export function taskChatIntroText(task: {
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
dueAt: string;
|
||||
}) {
|
||||
return [
|
||||
`【新任务】${text(task.name)}`,
|
||||
`品牌:${text(task.brand)}|数量:${task.quantity} 份|截止:${text(task.dueAt)}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -96,7 +96,7 @@ function parseDueDays(value: string | undefined) {
|
||||
return Math.min(30, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function hasAppCredentials(config: WecomConfig) {
|
||||
export function hasAppCredentials(config: WecomConfig) {
|
||||
return Boolean(config.corpId && config.agentId && config.secret);
|
||||
}
|
||||
|
||||
@@ -635,3 +635,135 @@ export async function fetchWecomExternalContactName(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function postAppChat(
|
||||
config: WecomConfig,
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
errorLabel: string,
|
||||
fetchImpl: FetchLike,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!hasAppCredentials(config)) {
|
||||
throw new WecomClientError(
|
||||
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const token = await fetchAccessToken(config, fetchImpl);
|
||||
const url = `${WECOM_API_ORIGIN}${path}?access_token=${encodeURIComponent(token)}`;
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const payload = await readEnvelope(response, errorLabel);
|
||||
if (!response.ok) {
|
||||
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
|
||||
}
|
||||
ensureOk(payload, errorLabel);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function createAppChat(
|
||||
config: WecomConfig,
|
||||
params: { name: string; userlist: string[]; chatid: string },
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<{ chatid: string }> {
|
||||
const userlist = params.userlist
|
||||
.map((id) => bindingValue(id))
|
||||
.filter((id) => id.length > 0);
|
||||
if (userlist.length < 2) {
|
||||
throw new WecomClientError("创建群聊会话至少需要 2 名成员", 400);
|
||||
}
|
||||
const chatid = bindingValue(params.chatid);
|
||||
if (!chatid || chatid.length > 32) {
|
||||
throw new WecomClientError("群聊 chatid 缺失或超过 32 字符", 400);
|
||||
}
|
||||
const name = bindingValue(params.name).slice(0, 20);
|
||||
const payload = await postAppChat(
|
||||
config,
|
||||
"/cgi-bin/appchat/create",
|
||||
{ name, owner: Number(config.agentId), userlist, chatid },
|
||||
"创建企业微信群聊会话失败",
|
||||
fetchImpl,
|
||||
);
|
||||
return { chatid: bindingValue(payload.chatid) || chatid };
|
||||
}
|
||||
|
||||
export async function updateAppChat(
|
||||
config: WecomConfig,
|
||||
params: {
|
||||
chatid: string;
|
||||
addUserList?: string[];
|
||||
delUserList?: string[];
|
||||
},
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<void> {
|
||||
const body: Record<string, unknown> = {
|
||||
chatid: bindingValue(params.chatid),
|
||||
};
|
||||
const addUserList = (params.addUserList ?? [])
|
||||
.map((id) => bindingValue(id))
|
||||
.filter((id) => id.length > 0);
|
||||
const delUserList = (params.delUserList ?? [])
|
||||
.map((id) => bindingValue(id))
|
||||
.filter((id) => id.length > 0);
|
||||
if (addUserList.length === 0 && delUserList.length === 0) return;
|
||||
if (addUserList.length > 0) body.add_user_list = addUserList;
|
||||
if (delUserList.length > 0) body.del_user_list = delUserList;
|
||||
await postAppChat(
|
||||
config,
|
||||
"/cgi-bin/appchat/update",
|
||||
body,
|
||||
"更新企业微信群聊会话失败",
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAppChat(
|
||||
config: WecomConfig,
|
||||
chatid: string,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<{ chatId: string; name: string; userlist: string[] } | null> {
|
||||
try {
|
||||
const payload = await postAppChat(
|
||||
config,
|
||||
"/cgi-bin/appchat/get",
|
||||
{ chatid: bindingValue(chatid) },
|
||||
"获取企业微信群聊会话失败",
|
||||
fetchImpl,
|
||||
);
|
||||
const detail = payload.chat_info as
|
||||
| { chatid?: unknown; name?: unknown; userlist?: unknown[] }
|
||||
| undefined;
|
||||
if (!detail) return null;
|
||||
return {
|
||||
chatId: bindingValue(detail.chatid),
|
||||
name: bindingValue(detail.name),
|
||||
userlist: Array.isArray(detail.userlist)
|
||||
? detail.userlist.map((id: unknown) => bindingValue(id)).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAppChatMessage(
|
||||
config: WecomConfig,
|
||||
params: { chatid: string; text: string },
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<void> {
|
||||
const chatid = bindingValue(params.chatid);
|
||||
const content = bindingValue(params.text);
|
||||
if (!chatid || !content) return;
|
||||
await postAppChat(
|
||||
config,
|
||||
"/cgi-bin/appchat/send",
|
||||
{ chatid, safe: 0, msgtype: "text", text: { content } },
|
||||
"发送企业微信群聊消息失败",
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
sendWecomTemplateCard,
|
||||
type WecomConfig,
|
||||
} from "./wecom-client";
|
||||
import { sendTaskChatMessage } from "./task-group-service";
|
||||
import { getRuntimeEnv } from "./runtime-env";
|
||||
import { releaseUnfinishedDistribution } from "./distribution-release-service";
|
||||
|
||||
@@ -24,6 +25,7 @@ type DueSoonRow = {
|
||||
partner_id: string;
|
||||
partner_name: string;
|
||||
wecom_external_user_id: string | null;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
due_at: string;
|
||||
content_title: string;
|
||||
@@ -56,6 +58,7 @@ export async function runDueSoonWecomNotifications(
|
||||
d.partner_id,
|
||||
p.name AS partner_name,
|
||||
p.wecom_external_user_id,
|
||||
t.id AS task_id,
|
||||
t.name AS task_name,
|
||||
t.due_at,
|
||||
c.title AS content_title
|
||||
@@ -140,6 +143,31 @@ export async function runDueSoonWecomNotifications(
|
||||
);
|
||||
}
|
||||
|
||||
const byTask = new Map<
|
||||
string,
|
||||
{ taskName: string; dueAt: string; rows: DueSoonRow[] }
|
||||
>();
|
||||
for (const row of result.results) {
|
||||
const entry = byTask.get(row.task_id) ?? {
|
||||
taskName: row.task_name,
|
||||
dueAt: row.due_at,
|
||||
rows: [],
|
||||
};
|
||||
entry.rows.push(row);
|
||||
byTask.set(row.task_id, entry);
|
||||
}
|
||||
for (const [taskId, entry] of byTask) {
|
||||
const partners = [...new Set(entry.rows.map((row) => row.partner_name))];
|
||||
const overflow = partners.length > 5 ? ` 等 ${partners.length} 位` : "";
|
||||
await sendTaskChatMessage(
|
||||
db,
|
||||
taskId,
|
||||
`【催办】《${truncate(entry.taskName, 24)}》有 ${entry.rows.length} 条内容待发布(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
|
||||
config,
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
let digestSent = false;
|
||||
if (config.robotWebhook && digestTasks.length > 0) {
|
||||
const digest =
|
||||
@@ -276,6 +304,13 @@ export async function runOverdueEscalation(
|
||||
const env = getRuntimeEnv();
|
||||
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
|
||||
for (const [taskId, entry] of byTask) {
|
||||
await sendTaskChatMessage(
|
||||
db,
|
||||
taskId,
|
||||
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`,
|
||||
config,
|
||||
fetchImpl,
|
||||
);
|
||||
if (!entry.projectId) continue;
|
||||
try {
|
||||
const project = await db
|
||||
|
||||
@@ -8,6 +8,7 @@ const WECOM_IDENTITY_GRACE_MS = 60 * 1_000;
|
||||
type WecomIdentityPayload = {
|
||||
v: 1;
|
||||
ext: string;
|
||||
uid?: string;
|
||||
name: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
@@ -17,6 +18,7 @@ type WecomIdentityPayload = {
|
||||
export type WecomIdentity = {
|
||||
externalUserId: string;
|
||||
displayName: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export function getWecomOAuthSecret(): string {
|
||||
@@ -74,13 +76,19 @@ function constantTimeEquals(a: Uint8Array, b: Uint8Array): boolean {
|
||||
export async function signWecomIdentityTicket(
|
||||
externalUserId: string,
|
||||
displayName: string,
|
||||
options: { secret?: string; nowMs?: number; ttlMs?: number } = {},
|
||||
options: {
|
||||
secret?: string;
|
||||
nowMs?: number;
|
||||
ttlMs?: number;
|
||||
userId?: string;
|
||||
} = {},
|
||||
): Promise<{ ticket: string; expiresIn: number }> {
|
||||
const secret = options.secret ?? getWecomOAuthSecret();
|
||||
const issuedAt = options.nowMs ?? Date.now();
|
||||
const ttl = options.ttlMs ?? WECOM_IDENTITY_TTL_MS;
|
||||
const ext = String(externalUserId ?? "").trim();
|
||||
if (!ext) throw new Error("缺少企微外部联系人 ID");
|
||||
const uid = String(options.userId ?? "").trim().slice(0, 64);
|
||||
if (!ext && !uid) throw new Error("缺少企微身份 ID(外部联系人或内部成员)");
|
||||
const payload: WecomIdentityPayload = {
|
||||
v: 1,
|
||||
ext,
|
||||
@@ -89,6 +97,7 @@ export async function signWecomIdentityTicket(
|
||||
exp: issuedAt + ttl,
|
||||
jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))),
|
||||
};
|
||||
if (uid) payload.uid = uid;
|
||||
const payloadSegment = toBase64Url(
|
||||
new TextEncoder().encode(JSON.stringify(payload)),
|
||||
);
|
||||
@@ -122,11 +131,14 @@ export async function verifyWecomIdentityTicket(
|
||||
if (
|
||||
payload?.v !== 1 ||
|
||||
typeof payload.ext !== "string" ||
|
||||
!payload.ext.trim() ||
|
||||
typeof payload.exp !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const extId = payload.ext.trim();
|
||||
const uid =
|
||||
typeof payload.uid === "string" ? payload.uid.trim().slice(0, 64) : "";
|
||||
if (!extId && !uid) return null;
|
||||
const secret = options.secret ?? getWecomOAuthSecret();
|
||||
const expected = await hmacSha256(secret, payloadSegment);
|
||||
const provided = fromBase64Url(signatureSegment);
|
||||
@@ -134,8 +146,9 @@ export async function verifyWecomIdentityTicket(
|
||||
const now = options.nowMs ?? Date.now();
|
||||
if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null;
|
||||
return {
|
||||
externalUserId: payload.ext.trim(),
|
||||
externalUserId: extId,
|
||||
displayName: payload.name || "企微客户",
|
||||
...(uid ? { userId: uid } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user