mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
fix(admin): make bulk delete resilient + persistent error toasts
This commit is contained in:
+180
-93
@@ -1166,16 +1166,16 @@ app.get("/", async (c) => {
|
|||||||
animateRowRemoval(row, () => {
|
animateRowRemoval(row, () => {
|
||||||
refreshFeedRowCache();
|
refreshFeedRowCache();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (toast && toast.update) {
|
if (toast && toast.update) {
|
||||||
toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false, duration: 6500 });
|
toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false });
|
||||||
} else if (window.showToast) {
|
} else if (window.showToast) {
|
||||||
window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 6500 });
|
window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error' });
|
||||||
}
|
}
|
||||||
setButtonLoading(button, false);
|
setButtonLoading(button, false);
|
||||||
confirming = false;
|
confirming = false;
|
||||||
resetDeleteButton(button);
|
resetDeleteButton(button);
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
setButtonLoading(button, false);
|
setButtonLoading(button, false);
|
||||||
@@ -1362,10 +1362,10 @@ app.get("/", async (c) => {
|
|||||||
|
|
||||||
const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch;
|
const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch;
|
||||||
const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : [];
|
const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : [];
|
||||||
|
const failureDetails = Array.isArray(data.failures) ? data.failures : [];
|
||||||
|
|
||||||
removeFeedRowsById(deletedIds);
|
removeFeedRowsById(deletedIds);
|
||||||
deletedTotal += deletedIds.length;
|
deletedTotal += deletedIds.length;
|
||||||
failed.push(...failedIds);
|
|
||||||
|
|
||||||
if (toast && toast.update) {
|
if (toast && toast.update) {
|
||||||
const done = Math.min(i + batch.length, selectedIds.length);
|
const done = Math.min(i + batch.length, selectedIds.length);
|
||||||
@@ -1375,14 +1375,62 @@ app.get("/", async (c) => {
|
|||||||
// Keep selection state consistent as rows disappear.
|
// Keep selection state consistent as rows disappear.
|
||||||
updateFeedMatchCount();
|
updateFeedMatchCount();
|
||||||
updateFeedSelectionState();
|
updateFeedSelectionState();
|
||||||
|
|
||||||
|
// If a batch fails for some feeds, retry those one-by-one using the single delete endpoint.
|
||||||
|
if (failedIds.length > 0) {
|
||||||
|
if (toast && toast.update) {
|
||||||
|
toast.update('Retrying ' + failedIds.length + ' failed feed(s) one-by-one...', { type: 'info' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillFailed = [];
|
||||||
|
for (let j = 0; j < failedIds.length; j++) {
|
||||||
|
const feedId = String(failedIds[j] || '');
|
||||||
|
if (!feedId) continue;
|
||||||
|
try {
|
||||||
|
const retryRes = await fetch('/admin/feeds/' + encodeURIComponent(feedId) + '/delete?view=table', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (window.parseJsonResponseOrThrow) {
|
||||||
|
await window.parseJsonResponseOrThrow(retryRes, { prefix: 'Retry delete failed' });
|
||||||
|
} else if (!retryRes.ok) {
|
||||||
|
throw new Error('Retry delete failed (HTTP ' + retryRes.status + ')');
|
||||||
|
}
|
||||||
|
removeFeedRowsById([feedId]);
|
||||||
|
deletedTotal += 1;
|
||||||
|
} catch (e) {
|
||||||
|
stillFailed.push(feedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toast && toast.update) {
|
||||||
|
toast.update('Retrying... (' + (j + 1) + ' of ' + failedIds.length + ')', { type: 'info' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace failed ids from this batch with only the ones that still failed after retry.
|
||||||
|
if (stillFailed.length > 0) {
|
||||||
|
failed.push(...stillFailed);
|
||||||
|
if (window.showToast && failureDetails.length > 0) {
|
||||||
|
const first = failureDetails[0] && failureDetails[0].error ? String(failureDetails[0].error) : '';
|
||||||
|
if (first) {
|
||||||
|
window.showToast('Some feeds failed to delete: ' + first, { type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFeedMatchCount();
|
||||||
|
updateFeedSelectionState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toast && toast.dismiss) toast.dismiss();
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
if (failed.length > 0) {
|
const uniqueFailed = Array.from(new Set(failed.map((v) => String(v)).filter(Boolean)));
|
||||||
|
if (uniqueFailed.length > 0) {
|
||||||
if (window.showToast) {
|
if (window.showToast) {
|
||||||
window.showToast(
|
window.showToast(
|
||||||
'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.',
|
'Deleted ' + deletedTotal + ' feed(s). ' + uniqueFailed.length + ' failed (still visible).',
|
||||||
{ type: 'error', duration: 6500 },
|
{ type: 'error' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1391,7 +1439,7 @@ app.get("/", async (c) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (toast && toast.dismiss) toast.dismiss();
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
if (window.showToast) {
|
if (window.showToast) {
|
||||||
window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error', duration: 7500 });
|
window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error' });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
FEED_BULK_DELETE_IN_PROGRESS = false;
|
FEED_BULK_DELETE_IN_PROGRESS = false;
|
||||||
@@ -1655,15 +1703,45 @@ async function deleteFeedFast(
|
|||||||
emailStorage: KVNamespace,
|
emailStorage: KVNamespace,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteFeedFastResult = {
|
||||||
|
// "ok" means the feed is deactivated (config deleted). Metadata is best-effort.
|
||||||
|
ok: boolean;
|
||||||
|
configDeleted: boolean;
|
||||||
|
metadataDeleted: boolean;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteFeedFastDetailed(
|
||||||
|
emailStorage: KVNamespace,
|
||||||
|
feedId: string,
|
||||||
|
): Promise<DeleteFeedFastResult> {
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
|
||||||
const results = await Promise.allSettled([
|
const errors: string[] = [];
|
||||||
emailStorage.delete(feedConfigKey),
|
let configDeleted = false;
|
||||||
emailStorage.delete(feedMetadataKey),
|
let metadataDeleted = false;
|
||||||
]);
|
|
||||||
|
|
||||||
return results.every((r) => r.status === "fulfilled");
|
try {
|
||||||
|
await emailStorage.delete(feedConfigKey);
|
||||||
|
configDeleted = true;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`config delete failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: if config is gone the feed is effectively disabled.
|
||||||
|
try {
|
||||||
|
await emailStorage.delete(feedMetadataKey);
|
||||||
|
metadataDeleted = true;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`metadata delete failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: configDeleted, configDeleted, metadataDeleted, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purgeFeedKeysStep(
|
async function purgeFeedKeysStep(
|
||||||
@@ -1679,7 +1757,7 @@ async function purgeFeedKeysStep(
|
|||||||
const prefix = `feed:${feedId}:`;
|
const prefix = `feed:${feedId}:`;
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
1000,
|
1000,
|
||||||
Math.max(1, Math.floor(options.limit || 250)),
|
Math.max(1, Math.floor(options.limit || 100)),
|
||||||
);
|
);
|
||||||
const cursor = options.cursor || undefined;
|
const cursor = options.cursor || undefined;
|
||||||
|
|
||||||
@@ -1736,9 +1814,10 @@ app.post("/feeds/:feedId/purge", async (c) => {
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
const cursor = body?.cursor ? String(body.cursor) : undefined;
|
const cursor = body?.cursor ? String(body.cursor) : undefined;
|
||||||
|
// Keep purge requests small to avoid Cloudflare per-request limits.
|
||||||
const limit = Number.isFinite(Number(body?.limit))
|
const limit = Number.isFinite(Number(body?.limit))
|
||||||
? Number(body?.limit)
|
? Number(body?.limit)
|
||||||
: 250;
|
: 100;
|
||||||
|
|
||||||
const step = await purgeFeedKeysStep(emailStorage, feedId, {
|
const step = await purgeFeedKeysStep(emailStorage, feedId, {
|
||||||
cursor,
|
cursor,
|
||||||
@@ -1794,40 +1873,61 @@ app.post("/feeds/bulk-delete", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Array<{ feedId: string; ok: boolean }> = [];
|
const okIds: string[] = [];
|
||||||
const concurrency = 10;
|
const failures: Array<{ feedId: string; error: string }> = [];
|
||||||
|
const warnings: Array<{ feedId: string; warning: string }> = [];
|
||||||
|
|
||||||
for (let i = 0; i < parsedFeedIds.length; i += concurrency) {
|
// Keep this request intentionally small/cheap (the UI already batches calls).
|
||||||
const batch = parsedFeedIds.slice(i, i + concurrency);
|
for (const feedId of parsedFeedIds) {
|
||||||
const batchResults = await Promise.all(
|
try {
|
||||||
batch.map(async (feedId) => {
|
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
||||||
try {
|
if (!result.ok) {
|
||||||
const ok = await deleteFeedFast(emailStorage, feedId);
|
failures.push({
|
||||||
return { feedId, ok };
|
feedId,
|
||||||
} catch (error) {
|
error:
|
||||||
console.error("Error bulk deleting feed:", feedId, error);
|
result.errors.join("; ") ||
|
||||||
return { feedId, ok: false };
|
"Failed to delete feed config (feed may still be active).",
|
||||||
}
|
});
|
||||||
}),
|
continue;
|
||||||
);
|
}
|
||||||
results.push(...batchResults);
|
|
||||||
|
if (!result.metadataDeleted) {
|
||||||
|
warnings.push({
|
||||||
|
feedId,
|
||||||
|
warning:
|
||||||
|
"Feed config deleted, but metadata cleanup failed. This is usually safe, but storage cleanup may be incomplete.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
okIds.push(feedId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error bulk deleting feed:", feedId, error);
|
||||||
|
failures.push({ feedId, error: String(error) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const okIds = results.filter((r) => r.ok).map((r) => r.feedId);
|
|
||||||
const failedFeedIds = results.filter((r) => !r.ok).map((r) => r.feedId);
|
|
||||||
|
|
||||||
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
||||||
|
|
||||||
// Best-effort: kick off small purge steps in the background so storage starts clearing.
|
// If config deletion succeeded but list removal didn't, surface it explicitly.
|
||||||
// The UI also runs purge steps for full cleanup + progress.
|
const removed = new Set(deletedFeedIds);
|
||||||
deletedFeedIds.forEach((feedId) => {
|
okIds.forEach((feedId) => {
|
||||||
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
|
if (!removed.has(feedId)) {
|
||||||
|
failures.push({
|
||||||
|
feedId,
|
||||||
|
error:
|
||||||
|
"Feed config deleted, but failed to remove it from feeds:list. Refresh and try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const failedFeedIds = Array.from(new Set(failures.map((f) => f.feedId)));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ok: failedFeedIds.length === 0,
|
ok: failedFeedIds.length === 0,
|
||||||
deletedFeedIds,
|
deletedFeedIds,
|
||||||
failedFeedIds,
|
failedFeedIds,
|
||||||
|
failures,
|
||||||
|
warnings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1841,32 +1941,19 @@ app.post("/feeds/bulk-delete", async (c) => {
|
|||||||
return c.redirect(`${redirectBase}&message=bulkDeleteNoop`);
|
return c.redirect(`${redirectBase}&message=bulkDeleteNoop`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Array<{ feedId: string; ok: boolean }> = [];
|
const okIds: string[] = [];
|
||||||
const concurrency = 10;
|
|
||||||
|
|
||||||
for (let i = 0; i < parsedFeedIds.length; i += concurrency) {
|
for (const feedId of parsedFeedIds) {
|
||||||
const batch = parsedFeedIds.slice(i, i + concurrency);
|
try {
|
||||||
const batchResults = await Promise.all(
|
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
||||||
batch.map(async (feedId) => {
|
if (result.ok) okIds.push(feedId);
|
||||||
try {
|
} catch (error) {
|
||||||
const ok = await deleteFeedFast(emailStorage, feedId);
|
console.error("Error bulk deleting feed:", feedId, error);
|
||||||
return { feedId, ok };
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("Error bulk deleting feed:", feedId, error);
|
|
||||||
return { feedId, ok: false };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
results.push(...batchResults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const okIds = results.filter((r) => r.ok).map((r) => r.feedId);
|
|
||||||
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
||||||
|
|
||||||
deletedFeedIds.forEach((feedId) => {
|
|
||||||
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.redirect(
|
return c.redirect(
|
||||||
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
|
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
|
||||||
);
|
);
|
||||||
@@ -2509,16 +2596,16 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
animateEmailRowRemoval(row, () => {
|
animateEmailRowRemoval(row, () => {
|
||||||
refreshEmailRowCache();
|
refreshEmailRowCache();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (toast && toast.update) {
|
if (toast && toast.update) {
|
||||||
toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false, duration: 6500 });
|
toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false });
|
||||||
} else if (window.showToast) {
|
} else if (window.showToast) {
|
||||||
window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 6500 });
|
window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error' });
|
||||||
}
|
}
|
||||||
setButtonLoading(button, false);
|
setButtonLoading(button, false);
|
||||||
confirming = false;
|
confirming = false;
|
||||||
resetEmailDeleteButton(button);
|
resetEmailDeleteButton(button);
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
setButtonLoading(button, false);
|
setButtonLoading(button, false);
|
||||||
@@ -2714,23 +2801,23 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
updateEmailSelectionState();
|
updateEmailSelectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toast && toast.dismiss) toast.dismiss();
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
if (failed.length > 0) {
|
if (failed.length > 0) {
|
||||||
if (window.showToast) {
|
if (window.showToast) {
|
||||||
window.showToast(
|
window.showToast(
|
||||||
'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.',
|
'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible).',
|
||||||
{ type: 'error', duration: 6500 },
|
{ type: 'error' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' email(s).', { type: 'success' });
|
if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' email(s).', { type: 'success' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (toast && toast.dismiss) toast.dismiss();
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
if (window.showToast) {
|
if (window.showToast) {
|
||||||
window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error', duration: 7500 });
|
window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error' });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
EMAIL_BULK_DELETE_IN_PROGRESS = false;
|
EMAIL_BULK_DELETE_IN_PROGRESS = false;
|
||||||
setButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, false);
|
setButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, false);
|
||||||
updateEmailSelectionState();
|
updateEmailSelectionState();
|
||||||
|
|||||||
+26
-15
@@ -48,14 +48,29 @@ export const toastScripts = `
|
|||||||
toast.appendChild(close);
|
toast.appendChild(close);
|
||||||
|
|
||||||
return { toast, text, close, spinner, body };
|
return { toast, text, close, spinner, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showToast(message, opts) {
|
||||||
|
const options = opts || {};
|
||||||
|
const type = (options && typeof options.type === 'string') ? String(options.type) : 'info';
|
||||||
|
const loading = !!(options && options.loading);
|
||||||
|
|
||||||
function showToast(message, opts) {
|
// Default durations:
|
||||||
const options = opts || {};
|
// - error: persistent (user must dismiss) so messages can be copied and acted on
|
||||||
const duration = Number.isFinite(options.duration) ? Number(options.duration) : 4500;
|
// - info/success/warning: auto-dismiss (notifications)
|
||||||
|
const defaultDurationByType = {
|
||||||
|
info: 4500,
|
||||||
|
success: 3500,
|
||||||
|
warning: 6500,
|
||||||
|
error: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const stack = ensureToastStack();
|
const duration = Number.isFinite(options.duration)
|
||||||
const { toast, text, close, body } = createToastEl(message, options);
|
? Number(options.duration)
|
||||||
|
: (loading ? 0 : (defaultDurationByType[type] ?? 4500));
|
||||||
|
|
||||||
|
const stack = ensureToastStack();
|
||||||
|
const { toast, text, close, body } = createToastEl(message, options);
|
||||||
|
|
||||||
let dismissed = false;
|
let dismissed = false;
|
||||||
let timeoutId = 0;
|
let timeoutId = 0;
|
||||||
@@ -102,15 +117,11 @@ export const toastScripts = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close.addEventListener('click', dismiss);
|
close.addEventListener('click', dismiss);
|
||||||
toast.addEventListener('click', (e) => {
|
// Don't dismiss on toast-body clicks: people should be able to select/copy text.
|
||||||
// Clicking the toast itself dismisses, but keep buttons functional
|
|
||||||
if (e.target === close) return;
|
stack.appendChild(toast);
|
||||||
dismiss();
|
requestAnimationFrame(() => toast.classList.add('visible'));
|
||||||
});
|
|
||||||
|
|
||||||
stack.appendChild(toast);
|
|
||||||
requestAnimationFrame(() => toast.classList.add('visible'));
|
|
||||||
|
|
||||||
// duration: 0 means "persistent"
|
// duration: 0 means "persistent"
|
||||||
scheduleDismiss(currentDuration);
|
scheduleDismiss(currentDuration);
|
||||||
|
|||||||
@@ -968,6 +968,7 @@ export const componentStyles = `
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-close {
|
.toast-close {
|
||||||
|
|||||||
Reference in New Issue
Block a user