mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(geo): send a one-shot admin notification when the offline databases are missing
The runtime gate for the resilient GeoLite2 fallback. On the first `lookupIpLocation` or `lookupIpAsn` call that has to leave the offline tier, the resolver checks `offlineGeoReady()` — false if the geo asset directory carries the `.empty` marker or the City MMDB is absent — and fires `dispatchLocalisedNotification` to every admin user, exactly once per process lifetime, with a body that points the maintainer at the GitHub Actions secrets page where `MAXMIND_LICENSE_KEY` belongs. Adds the two translation keys `notifications.admin.offlineGeoUnavailableTitle` / `offlineGeoUnavailableBody` to every locale bundle, locks them down in the drift-guard, and covers the latch, the marker, the no-admin path and the ASN fallback with 8 new vitest cases under `geo-offline-detection.test.ts`.
This commit is contained in:
1 parent
1fe424da0b
commit
53c9e43795
9 files changed
+305
-7
No files matched your search
+3
-1
@@ -2650,7 +2650,9 @@
|
||||
"reminderCheckMissedTitle": "Erinnerung verpasst: {medication}",
|
||||
"reminderCheckMissedBody": "{medication} ({dose}) im Zeitfenster {window} wurde nicht geloggt.",
|
||||
"reminderCheckOverdueTitle": "Einnahme überfällig: {medication}",
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig."
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.",
|
||||
"offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen",
|
||||
"offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
|
||||
|
||||
+3
-1
@@ -2650,7 +2650,9 @@
|
||||
"reminderCheckMissedTitle": "Reminder missed: {medication}",
|
||||
"reminderCheckMissedBody": "{medication} ({dose}) was not logged in the {window} window.",
|
||||
"reminderCheckOverdueTitle": "Dose overdue: {medication}",
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue."
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.",
|
||||
"offlineGeoUnavailableTitle": "Offline geolocation databases not loaded",
|
||||
"offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: connection successful. Telegram notifications are active."
|
||||
|
||||
+3
-1
@@ -2650,7 +2650,9 @@
|
||||
"reminderCheckMissedTitle": "Recordatorio perdido: {medication}",
|
||||
"reminderCheckMissedBody": "{medication} ({dose}) no se registró en la ventana {window}.",
|
||||
"reminderCheckOverdueTitle": "Dosis atrasada: {medication}",
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso."
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.",
|
||||
"offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas",
|
||||
"offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas."
|
||||
|
||||
+3
-1
@@ -2650,7 +2650,9 @@
|
||||
"reminderCheckMissedTitle": "Rappel manqué : {medication}",
|
||||
"reminderCheckMissedBody": "{medication} ({dose}) n'a pas été enregistré dans la fenêtre {window}.",
|
||||
"reminderCheckOverdueTitle": "Prise en retard : {medication}",
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes."
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.",
|
||||
"offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées",
|
||||
"offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives."
|
||||
|
||||
+3
-1
@@ -2650,7 +2650,9 @@
|
||||
"reminderCheckMissedTitle": "Promemoria perso: {medication}",
|
||||
"reminderCheckMissedBody": "{medication} ({dose}) non è stato registrato nella finestra {window}.",
|
||||
"reminderCheckOverdueTitle": "Dose in ritardo: {medication}",
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti."
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.",
|
||||
"offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati",
|
||||
"offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive."
|
||||
|
||||
+3
-1
@@ -2650,7 +2650,9 @@
|
||||
"reminderCheckMissedTitle": "Pominięte przypomnienie: {medication}",
|
||||
"reminderCheckMissedBody": "{medication} ({dose}) nie zostało zarejestrowane w przedziale {window}.",
|
||||
"reminderCheckOverdueTitle": "Dawka opóźniona: {medication}",
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut."
|
||||
"reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.",
|
||||
"offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane",
|
||||
"offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne."
|
||||
|
||||
@@ -122,6 +122,9 @@ const REQUIRED_KEY_SETS: Array<{
|
||||
"notifications.admin.reminderCheckMissedBody",
|
||||
"notifications.admin.reminderCheckOverdueTitle",
|
||||
"notifications.admin.reminderCheckOverdueBody",
|
||||
// v1.4.27 R5 — offline-geo fallback notifications.
|
||||
"notifications.admin.offlineGeoUnavailableTitle",
|
||||
"notifications.admin.offlineGeoUnavailableBody",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.4.27 R5 — runtime detection + one-shot admin notification for the
|
||||
* offline GeoLite2 tier.
|
||||
*
|
||||
* The CI workflow drops an `.empty` marker into `assets/geolite2/`
|
||||
* whenever `MAXMIND_LICENSE_KEY` is unset, so the Docker COPY still
|
||||
* lands a non-empty directory in `/opt/geolite2/`. The runtime resolver
|
||||
* detects the marker on first fallback, fires a single localised
|
||||
* notification to every admin, and stays silent for the rest of the
|
||||
* process lifetime.
|
||||
*
|
||||
* The tests cover the three states the design pins:
|
||||
*
|
||||
* 1. `.empty` marker present, City MMDB absent → notification fires
|
||||
* on first public-IP lookup.
|
||||
* 2. No marker, City MMDB present → no notification fires.
|
||||
* 3. Marker present, repeat lookups → notification fires once and
|
||||
* never again.
|
||||
*
|
||||
* `mmdb-lib` is stubbed via the same shape the existing
|
||||
* `geo-asn.test.ts` uses; `fs` is left real because `offlineGeoReady`
|
||||
* reads real files at the temp-dir path picked per test, and the
|
||||
* `dispatchLocalisedNotification` + `prisma.user.findMany` are mocked
|
||||
* so the test never reaches a database or a sender.
|
||||
*/
|
||||
|
||||
vi.mock("mmdb-lib", () => ({
|
||||
Reader: class {
|
||||
constructor(_buf: Buffer) {
|
||||
void _buf;
|
||||
}
|
||||
get(ip: string): unknown {
|
||||
void ip;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const dispatchSpy = vi.fn(async () => undefined);
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: dispatchSpy,
|
||||
}));
|
||||
|
||||
const findManySpy = vi.fn(async () => [{ id: "admin-1" }]);
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findMany: findManySpy },
|
||||
},
|
||||
}));
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
let tmpRoot: string;
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
// The notification path is fire-and-forget — `void notify…()` —
|
||||
// and pulls in `@/lib/db` + `@/lib/notifications/dispatch-localised`
|
||||
// through dynamic `import()` calls, so each dispatch needs several
|
||||
// microtask + macrotask flushes before the spy registers the call.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.IP_GEO_LOOKUP_DISABLED;
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-r5-"));
|
||||
process.env.GEOLITE2_DIR = tmpRoot;
|
||||
dispatchSpy.mockClear();
|
||||
findManySpy.mockClear();
|
||||
findManySpy.mockResolvedValue([{ id: "admin-1" }]);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("offline-geo runtime detection (v1.4.27 R5)", () => {
|
||||
it("offlineGeoReady() is false when the .empty marker is present", async () => {
|
||||
writeFileSync(join(tmpRoot, ".empty"), "");
|
||||
const { offlineGeoReady } = await import("../geo");
|
||||
expect(offlineGeoReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("offlineGeoReady() is false when the City MMDB is absent", async () => {
|
||||
const { offlineGeoReady } = await import("../geo");
|
||||
expect(offlineGeoReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => {
|
||||
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
|
||||
const { offlineGeoReady } = await import("../geo");
|
||||
expect(offlineGeoReady()).toBe(true);
|
||||
});
|
||||
|
||||
it("fires the one-shot admin notification when the marker is present and lookupIpLocation falls back", async () => {
|
||||
writeFileSync(join(tmpRoot, ".empty"), "");
|
||||
// Disable the online fallback so the test does not hit the network.
|
||||
process.env.IP_GEO_LOOKUP_DISABLED = "1";
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
await lookupIpLocation("8.8.8.8");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findManySpy).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
|
||||
messageKey: "notifications.admin.offlineGeoUnavailableBody",
|
||||
params: expect.objectContaining({
|
||||
secretsUrl: expect.stringContaining(
|
||||
"github.com/MBombeck/HealthLog/settings/secrets/actions",
|
||||
),
|
||||
}),
|
||||
metadata: expect.objectContaining({ source: "geo-offline-detection" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT fire the notification when the City MMDB is present", async () => {
|
||||
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
|
||||
// The stubbed reader returns null for every IP, which is fine — the
|
||||
// lookup will still go through the online path, but the offline
|
||||
// check sees a healthy directory so the notification stays silent.
|
||||
process.env.IP_GEO_LOOKUP_DISABLED = "1";
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
await lookupIpLocation("8.8.8.8");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(findManySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires the notification exactly once across repeated fallbacks (process-level latch)", async () => {
|
||||
writeFileSync(join(tmpRoot, ".empty"), "");
|
||||
process.env.IP_GEO_LOOKUP_DISABLED = "1";
|
||||
const { lookupIpLocation, lookupIpAsn } = await import("../geo");
|
||||
|
||||
await lookupIpLocation("8.8.8.8");
|
||||
await flushMicrotasks();
|
||||
await lookupIpLocation("1.1.1.1");
|
||||
await flushMicrotasks();
|
||||
lookupIpAsn("9.9.9.9");
|
||||
await flushMicrotasks();
|
||||
|
||||
// Three fallbacks, exactly one dispatch.
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back silently when no admin user is configured", async () => {
|
||||
writeFileSync(join(tmpRoot, ".empty"), "");
|
||||
process.env.IP_GEO_LOOKUP_DISABLED = "1";
|
||||
findManySpy.mockResolvedValueOnce([]);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
await lookupIpLocation("8.8.8.8");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findManySpy).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lookupIpAsn fires the notification when the ASN reader is missing and the marker is set", async () => {
|
||||
writeFileSync(join(tmpRoot, ".empty"), "");
|
||||
const { lookupIpAsn } = await import("../geo");
|
||||
|
||||
const result = lookupIpAsn("8.8.8.8");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+98
-1
@@ -48,6 +48,14 @@
|
||||
* The previous path lost umlauts in production for at least one
|
||||
* maintainer-flagged login row that rendered as "Nrnberg" in
|
||||
* /admin/login-overview — see `docs/audit/v1416-summary.md`.
|
||||
*
|
||||
* v1.4.27 R5: the build no longer hard-fails on a missing
|
||||
* `MAXMIND_LICENSE_KEY` secret — the CI workflow drops an `.empty`
|
||||
* marker into the geo asset directory when the key is unset.
|
||||
* `offlineGeoReady()` is the canonical check used by `/api/version`
|
||||
* and the admin status surface, and the lookup paths fire a one-shot
|
||||
* admin notification on the first fallback so the maintainer hears
|
||||
* about the gap from the running app.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -56,6 +64,7 @@ import type {
|
||||
AsnResponse,
|
||||
CityResponse,
|
||||
} from "mmdb-lib/lib/reader/response";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
interface IpwhoIsResponse {
|
||||
success?: boolean;
|
||||
@@ -133,6 +142,78 @@ function getAsnReader(): MmdbReader<AsnResponse> | null {
|
||||
export function __resetGeoLite2CacheForTests(): void {
|
||||
cache.city = undefined;
|
||||
cache.asn = undefined;
|
||||
notifiedThisProcess = false;
|
||||
}
|
||||
|
||||
// ── Offline readiness + one-shot admin notification ─────────────────
|
||||
//
|
||||
// The CI workflow drops an `.empty` marker into the geo asset directory
|
||||
// when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in
|
||||
// which case the City + ASN MMDBs are absent and every lookup hits the
|
||||
// online fallback. `offlineGeoReady()` is the canonical truth used by
|
||||
// `/api/version` and the admin status surface so the two paths cannot
|
||||
// disagree.
|
||||
//
|
||||
// `notifiedThisProcess` is a module-level latch so the admin only
|
||||
// receives the "offline geo is disabled" notification once per worker
|
||||
// boot — every subsequent fallback hit is silent. Test-only reset is
|
||||
// folded into the cache reset above.
|
||||
|
||||
let notifiedThisProcess = false;
|
||||
|
||||
export function offlineGeoReady(): boolean {
|
||||
try {
|
||||
const dir = geoLiteDir();
|
||||
if (fs.existsSync(path.join(dir, ".empty"))) return false;
|
||||
return fs.existsSync(path.join(dir, "GeoLite2-City.mmdb"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyOfflineGeoUnavailable(): Promise<void> {
|
||||
if (notifiedThisProcess) return;
|
||||
notifiedThisProcess = true;
|
||||
// Defer the resolve so we don't pay the cost of pulling the
|
||||
// Prisma client into the import graph until the first fallback —
|
||||
// most calls are cache hits or private-IP short-circuits.
|
||||
try {
|
||||
const [{ prisma }, { dispatchLocalisedNotification }] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
import("@/lib/notifications/dispatch-localised"),
|
||||
]);
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { role: "ADMIN" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (admins.length === 0) {
|
||||
getEvent()?.addWarning(
|
||||
"geo: offline databases unavailable, no admin user configured to notify",
|
||||
);
|
||||
return;
|
||||
}
|
||||
getEvent()?.addWarning(
|
||||
"geo: offline databases unavailable, falling back to ipwho.is — notifying admins",
|
||||
);
|
||||
for (const admin of admins) {
|
||||
await dispatchLocalisedNotification({
|
||||
userId: admin.id,
|
||||
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
|
||||
messageKey: "notifications.admin.offlineGeoUnavailableBody",
|
||||
params: {
|
||||
secretsUrl:
|
||||
"https://github.com/MBombeck/HealthLog/settings/secrets/actions",
|
||||
},
|
||||
metadata: { source: "geo-offline-detection" },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Never let a notification failure propagate — the auth-audit
|
||||
// caller is fire-and-forget.
|
||||
getEvent()?.addWarning(
|
||||
`geo: offline-geo notification failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,6 +325,14 @@ export async function lookupIpLocation(
|
||||
const offline = lookupIpLocationOffline(ip);
|
||||
if (offline) return offline;
|
||||
|
||||
// First public-IP lookup that has to leave the offline tier — if the
|
||||
// offline DBs are missing entirely, send the one-shot admin alert so
|
||||
// the maintainer can wire `MAXMIND_LICENSE_KEY` when convenient. The
|
||||
// notification is fire-and-forget so the audit-log path stays fast.
|
||||
if (!offlineGeoReady()) {
|
||||
void notifyOfflineGeoUnavailable();
|
||||
}
|
||||
|
||||
return lookupIpLocationOnline(ip);
|
||||
}
|
||||
|
||||
@@ -262,7 +351,15 @@ export function lookupIpAsn(
|
||||
): { asn: number; carrier: string | null } | null {
|
||||
if (!ip || PRIVATE_IP.test(ip)) return null;
|
||||
const reader = getAsnReader();
|
||||
if (!reader) return null;
|
||||
if (!reader) {
|
||||
// No offline ASN data — alert once per process. There is no online
|
||||
// fallback for ASN, but the maintainer still wants to know the
|
||||
// carrier chip is going to stay empty until the secret is set.
|
||||
if (!offlineGeoReady()) {
|
||||
void notifyOfflineGeoUnavailable();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const row = reader.get(ip);
|
||||
if (!row) return null;
|
||||
|
||||
Reference in new issue
Block a user