Update
This commit is contained in:
1 parent
235c19e51b
commit
227644cfeb
8 files changed
+1041
-309
No files matched your search
+193
-59
@@ -1097,6 +1097,32 @@ function parseProjectTeamspaceEntries(
|
||||
return entries;
|
||||
}
|
||||
|
||||
type ParsedProjectTeamspaceEntry = ReturnType<typeof parseProjectTeamspaceEntries>[number];
|
||||
|
||||
function budgetMinutesChanged(left: number | null | undefined, right: number | null | undefined) {
|
||||
return (left ?? null) !== (right ?? null);
|
||||
}
|
||||
|
||||
function protectNewManualBudgetEntries(entries: ParsedProjectTeamspaceEntry[]) {
|
||||
return entries.map((entry) => entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry);
|
||||
}
|
||||
|
||||
function protectChangedManualBudgetEntries(
|
||||
entries: ParsedProjectTeamspaceEntry[],
|
||||
currentEntries: Array<{ id: string; hour_budget_minutes: number | null }>
|
||||
) {
|
||||
const currentById = new Map(currentEntries.map((entry) => [entry.id, entry.hour_budget_minutes]));
|
||||
|
||||
return entries.map((entry) => {
|
||||
const currentBudget = entry.id ? currentById.get(String(entry.id)) : undefined;
|
||||
const manuallyChanged = entry.id
|
||||
? currentBudget !== undefined && budgetMinutesChanged(currentBudget, entry.hourBudgetMinutes)
|
||||
: entry.hourBudgetMinutes !== null;
|
||||
|
||||
return manuallyChanged ? { ...entry, budgetSyncDisabled: true } : entry;
|
||||
});
|
||||
}
|
||||
|
||||
function parseTodoPriority(value: unknown) {
|
||||
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
|
||||
throw badRequest("priority must be low, medium, high or urgent");
|
||||
@@ -1994,8 +2020,11 @@ function syncComparableValueForField(field: string, value: unknown) {
|
||||
return syncIdComparableValue(value);
|
||||
}
|
||||
|
||||
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
|
||||
return value.map((entry) => syncComparableValue(entry));
|
||||
if (field === "Teamspace-Projekte") {
|
||||
const entries = Array.isArray(value)
|
||||
? value.map((entry) => (typeof entry === "string" ? entry : null))
|
||||
: parseZammadTeamspaceProjects(value);
|
||||
return normalizedTeamspaceProjectList(entries).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
return syncComparableValue(value);
|
||||
@@ -2007,7 +2036,7 @@ function syncValueChanged(field: string, left: unknown, right: unknown) {
|
||||
|
||||
function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
|
||||
if (syncValueChanged(field, from, to)) {
|
||||
changes.push({ field, from: syncComparableValue(from), to: syncComparableValue(to) });
|
||||
changes.push({ field, from: syncComparableValueForField(field, from), to: syncComparableValueForField(field, to) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2068,7 +2097,11 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
|
||||
};
|
||||
}
|
||||
|
||||
function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = null) {
|
||||
function localSyncSnapshot(
|
||||
localTicket: LocalZammadSyncTicket,
|
||||
project: SyncProjectSnapshot | null = null,
|
||||
options: { zammadPendingTodoExists?: boolean | null } = {}
|
||||
) {
|
||||
return {
|
||||
ticketId: localTicket.id,
|
||||
ticketNumber: localTicket.ticket_number,
|
||||
@@ -2081,6 +2114,7 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj
|
||||
zammadPriorityId: localTicket.zammad_priority_id,
|
||||
zammadStateId: localTicket.zammad_state_id,
|
||||
zammadOwnerId: localTicket.zammad_owner_id,
|
||||
zammadPendingTodoExists: options.zammadPendingTodoExists ?? null,
|
||||
project: project
|
||||
? {
|
||||
id: project.id,
|
||||
@@ -2169,8 +2203,60 @@ function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) {
|
||||
return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : [];
|
||||
}
|
||||
|
||||
function normalizedTeamspaceProjectList(projects: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const value = project?.trim();
|
||||
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
normalized.push(value);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatTeamspaceProjectList(projects: Array<string | null | undefined>) {
|
||||
return projects.map((project) => project?.trim()).filter(Boolean).join(" + ");
|
||||
return normalizedTeamspaceProjectList(projects).join(" + ");
|
||||
}
|
||||
|
||||
function teamspaceProjectDiff(localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
|
||||
const local = normalizedTeamspaceProjectList(localProjects);
|
||||
const zammad = normalizedTeamspaceProjectList(zammadProjects);
|
||||
const localSet = new Set(local);
|
||||
const zammadSet = new Set(zammad);
|
||||
|
||||
return {
|
||||
local,
|
||||
zammad,
|
||||
additions: zammad.filter((project) => !localSet.has(project)),
|
||||
missingInZammad: local.filter((project) => !zammadSet.has(project))
|
||||
};
|
||||
}
|
||||
|
||||
function addTeamspaceProjectSyncChanges(changes: ZammadSyncChange[], localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
|
||||
const diff = teamspaceProjectDiff(localProjects, zammadProjects);
|
||||
|
||||
if (diff.additions.length > 0) {
|
||||
changes.push({
|
||||
field: "Teamspace-Projekte",
|
||||
from: diff.local,
|
||||
to: [...diff.local, ...diff.additions]
|
||||
});
|
||||
}
|
||||
|
||||
if (diff.missingInZammad.length > 0) {
|
||||
changes.push({
|
||||
field: "Teamspace-Prüfung",
|
||||
from: diff.missingInZammad,
|
||||
to: "nicht mehr aus Zammad; bitte manuell prüfen"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) {
|
||||
@@ -2224,7 +2310,16 @@ async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, us
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order)
|
||||
VALUES ($1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2));
|
||||
SELECT $1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ticket_todos
|
||||
WHERE ticket_id = $1
|
||||
AND user_id = $2
|
||||
AND title = $3
|
||||
AND starts_on = $5::date
|
||||
AND ends_on = $5::date
|
||||
);
|
||||
`,
|
||||
[ticketId, userId, payload.title, payload.description, payload.day]
|
||||
);
|
||||
@@ -2237,13 +2332,15 @@ async function syncProjectTeamspaceEntriesFromZammad(
|
||||
budgetMinutes: number | null,
|
||||
syncBudget: boolean
|
||||
) {
|
||||
if (teamspaceProjects.length === 0) {
|
||||
const zammadProjects = normalizedTeamspaceProjectList(teamspaceProjects);
|
||||
|
||||
if (zammadProjects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingResult = await client.query<{ id: string }>(
|
||||
const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>(
|
||||
`
|
||||
SELECT id::text AS id
|
||||
SELECT id::text AS id, teamspace_project, is_default, display_order
|
||||
FROM project_teamspace_entries
|
||||
WHERE project_id = $1
|
||||
ORDER BY is_default DESC, display_order ASC, id ASC
|
||||
@@ -2251,51 +2348,69 @@ async function syncProjectTeamspaceEntriesFromZammad(
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
const savedIds: string[] = [];
|
||||
|
||||
await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [projectId]);
|
||||
|
||||
for (const [index, teamspaceProject] of teamspaceProjects.entries()) {
|
||||
const existing = existingResult.rows[index];
|
||||
const isDefault = index === 0;
|
||||
|
||||
if (existing) {
|
||||
if (existingResult.rows.length === 0) {
|
||||
for (const [index, teamspaceProject] of zammadProjects.entries()) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE project_teamspace_entries
|
||||
SET teamspace_project = $1,
|
||||
is_default = $2,
|
||||
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
|
||||
budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END,
|
||||
display_order = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $6
|
||||
AND project_id = $7;
|
||||
`,
|
||||
[teamspaceProject, isDefault, syncBudget && isDefault, budgetMinutes, index * 1024, existing.id, projectId]
|
||||
);
|
||||
savedIds.push(existing.id);
|
||||
} else {
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`
|
||||
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
|
||||
VALUES ($1, $2, $3, $4, false, 0, $5)
|
||||
RETURNING id::text AS id;
|
||||
VALUES ($1, $2, $3, $4, false, 0, $5);
|
||||
`,
|
||||
[projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024]
|
||||
[projectId, teamspaceProject, index === 0, syncBudget && index === 0 ? budgetMinutes : null, index * 1024]
|
||||
);
|
||||
savedIds.push(inserted.rows[0].id);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE projects
|
||||
SET teamspace_project = $2,
|
||||
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
`,
|
||||
[projectId, zammadProjects[0] ?? null, syncBudget, budgetMinutes]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
DELETE FROM project_teamspace_entries
|
||||
WHERE project_id = $1
|
||||
AND NOT (id = ANY($2::bigint[]));
|
||||
`,
|
||||
[projectId, savedIds]
|
||||
);
|
||||
let defaultEntry = existingResult.rows.find((entry) => entry.is_default) ?? existingResult.rows[0];
|
||||
|
||||
if (!defaultEntry.is_default) {
|
||||
await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, defaultEntry.id]);
|
||||
defaultEntry = { ...defaultEntry, is_default: true };
|
||||
}
|
||||
|
||||
const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingResult.rows.map((entry) => entry.teamspace_project)));
|
||||
let nextDisplayOrder = Math.max(...existingResult.rows.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024;
|
||||
|
||||
for (const teamspaceProject of zammadProjects) {
|
||||
if (existingTeamspaceProjects.has(teamspaceProject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
|
||||
VALUES ($1, $2, false, NULL, false, 0, $3);
|
||||
`,
|
||||
[projectId, teamspaceProject, nextDisplayOrder]
|
||||
);
|
||||
nextDisplayOrder += 1024;
|
||||
}
|
||||
|
||||
if (syncBudget) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE project_teamspace_entries
|
||||
SET hour_budget_minutes = $1,
|
||||
budget_sync_disabled = false,
|
||||
updated_at = now()
|
||||
WHERE id = $2
|
||||
AND project_id = $3;
|
||||
`,
|
||||
[budgetMinutes, defaultEntry.id, projectId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -2305,7 +2420,7 @@ async function syncProjectTeamspaceEntriesFromZammad(
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
`,
|
||||
[projectId, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes]
|
||||
[projectId, defaultEntry.teamspace_project, syncBudget, budgetMinutes]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2317,12 +2432,13 @@ async function planExistingZammadTicketSync(
|
||||
fields: ZammadSyncExistingFields
|
||||
): Promise<ZammadSyncItem | null> {
|
||||
const changes: ZammadSyncChange[] = [];
|
||||
const pendingTodo = zammadPendingTodoPayload(zammadTicket);
|
||||
const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null;
|
||||
|
||||
addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId);
|
||||
addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId);
|
||||
|
||||
const pendingTodo = zammadPendingTodoPayload(zammadTicket);
|
||||
if (fields.pendingTodo && pendingTodo && !(await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket))) {
|
||||
if (fields.pendingTodo && pendingTodo && !pendingTodoExists) {
|
||||
changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` });
|
||||
}
|
||||
|
||||
@@ -2332,7 +2448,7 @@ async function planExistingZammadTicketSync(
|
||||
ticketNumber: localTicket.ticket_number,
|
||||
action: "update",
|
||||
reason: "Zammad-Metadaten werden aktualisiert.",
|
||||
local: localSyncSnapshot(localTicket),
|
||||
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
|
||||
zammad: zammadSyncSnapshot(zammadTicket),
|
||||
changes
|
||||
}
|
||||
@@ -2346,7 +2462,7 @@ async function planExistingZammadTicketSync(
|
||||
ticketNumber: localTicket.ticket_number,
|
||||
action: "skip",
|
||||
reason: "Zammad-Organisation ist lokal nicht synchronisiert.",
|
||||
local: localSyncSnapshot(localTicket),
|
||||
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
|
||||
zammad: zammadSyncSnapshot(zammadTicket),
|
||||
changes: []
|
||||
};
|
||||
@@ -2374,7 +2490,7 @@ async function planExistingZammadTicketSync(
|
||||
ticketNumber: localTicket.ticket_number,
|
||||
action: changes.length > 0 ? "update" : "skip",
|
||||
reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.",
|
||||
local: localSyncSnapshot(localTicket),
|
||||
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
|
||||
zammad: zammadSyncSnapshot(zammadTicket),
|
||||
changes
|
||||
};
|
||||
@@ -2390,7 +2506,7 @@ async function planExistingZammadTicketSync(
|
||||
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
|
||||
const localTeamspaceProjects = project?.teamspace_entries?.length
|
||||
? project.teamspace_entries.map((entry) => entry.teamspace_project)
|
||||
: [project?.teamspace_project ?? null];
|
||||
: project?.teamspace_project ? [project.teamspace_project] : [];
|
||||
const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget);
|
||||
|
||||
if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
|
||||
@@ -2402,7 +2518,7 @@ async function planExistingZammadTicketSync(
|
||||
}
|
||||
|
||||
if (fields.projectName) {
|
||||
addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
|
||||
addTeamspaceProjectSyncChanges(changes, localTeamspaceProjects, zammadTeamspaceProjects);
|
||||
}
|
||||
|
||||
if (canSyncBudget) {
|
||||
@@ -2424,7 +2540,7 @@ async function planExistingZammadTicketSync(
|
||||
ticketNumber: localTicket.ticket_number,
|
||||
action: changes.length > 0 ? "update" : "skip",
|
||||
reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.",
|
||||
local: localSyncSnapshot(localTicket, project),
|
||||
local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }),
|
||||
zammad: zammadSyncSnapshot(zammadTicket),
|
||||
changes
|
||||
};
|
||||
@@ -6230,6 +6346,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
||||
if (zammadTicket) {
|
||||
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
|
||||
const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||
const defaultBudgetManuallyChanged = currentDefaultTeamspaceEntry.hourBudgetMinutes !== null
|
||||
&& budgetMinutesChanged(currentDefaultTeamspaceEntry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes);
|
||||
|
||||
teamspaceEntries = zammadTeamspaceProjects.length > 0
|
||||
? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({
|
||||
@@ -6237,7 +6355,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
||||
teamspaceProject: teamspaceProjectName,
|
||||
isDefault: index === 0,
|
||||
hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null,
|
||||
budgetSyncDisabled: false,
|
||||
budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged),
|
||||
manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0,
|
||||
displayOrder: index * 1024
|
||||
}))
|
||||
@@ -6247,10 +6365,14 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
||||
...entry,
|
||||
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
|
||||
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes,
|
||||
budgetSyncDisabled: false
|
||||
budgetSyncDisabled: entry.budgetSyncDisabled || (entry.hourBudgetMinutes !== null && budgetMinutesChanged(entry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes))
|
||||
}
|
||||
: entry
|
||||
);
|
||||
)
|
||||
.map((entry) => !entry.isDefault && entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry);
|
||||
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||
} else {
|
||||
teamspaceEntries = protectNewManualBudgetEntries(teamspaceEntries);
|
||||
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||
}
|
||||
|
||||
@@ -6474,8 +6596,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
||||
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
|
||||
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
|
||||
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
|
||||
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
|
||||
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||
let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
|
||||
let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||
|
||||
if (startsOn && endsOn && endsOn < startsOn) {
|
||||
throw badRequest("endsOn must be after startsOn");
|
||||
@@ -6498,6 +6620,18 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>(
|
||||
`
|
||||
SELECT id::text AS id, hour_budget_minutes
|
||||
FROM project_teamspace_entries
|
||||
WHERE project_id = $1
|
||||
FOR UPDATE;
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
teamspaceEntries = protectChangedManualBudgetEntries(teamspaceEntries, currentTeamspaceEntries.rows);
|
||||
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||
|
||||
const organization = await getOrganizationByZammadId(client, organizationId);
|
||||
|
||||
if (!organization) {
|
||||
|
||||
Reference in new issue
Block a user