mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration
This commit is contained in:
@@ -39,6 +39,7 @@ export type NotifEventType =
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available'
|
||||
| 'replica_failure'
|
||||
| 'synology_session_cleared'
|
||||
| 'plugin_notification';
|
||||
|
||||
@@ -55,12 +56,13 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [
|
||||
'collab_message',
|
||||
'packing_tagged',
|
||||
'version_available',
|
||||
'replica_failure',
|
||||
'synology_session_cleared',
|
||||
'plugin_notification',
|
||||
];
|
||||
|
||||
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
|
||||
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available', 'replica_failure']);
|
||||
|
||||
// Compile-time guard: shared NotificationEventKey and server NotifEventType must
|
||||
// stay in sync. It sits next to the union it guards — it used to live in the
|
||||
|
||||
@@ -4,23 +4,36 @@ import { NotificationsMcp } from './notifications.mcp';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationPreferencesService } from './notification-preferences.service';
|
||||
import { ReminderJobsService } from './reminder-jobs.service';
|
||||
import { StorageHealthNotifierService } from './storage-health-notifier.service';
|
||||
import { NtfyService } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
/** Notifications domain (L6 leaf module). Registered in AppModule.
|
||||
* AuthModule feeds NotificationsMcp's demo gate; MailerModule carries SMTP,
|
||||
* which lives outside this module so AuthService can send the password-reset
|
||||
* mail without AuthModule and NotificationsModule importing each other.
|
||||
* StorageModule feeds StorageHealthNotifierService, which bridges replica
|
||||
* failures into admin notifications — this direction has no cycle (Storage
|
||||
* imports only AppConfig+Audit+Scheduling).
|
||||
* NotificationsService and NotificationPreferencesService are exported for
|
||||
* in-container consumers (AdminController's dev test send and preferences tab,
|
||||
* the plugin RPC surface, HostSurfaceRpc). */
|
||||
@Module({
|
||||
imports: [AuthModule, MailerModule, SchedulingModule],
|
||||
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
|
||||
controllers: [NotificationsController, AdminNotificationPreferencesController],
|
||||
providers: [NotificationsService, NotificationPreferencesService, WebhookService, NtfyService, NotificationsMcp, ReminderJobsService],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
NotificationPreferencesService,
|
||||
WebhookService,
|
||||
NtfyService,
|
||||
NotificationsMcp,
|
||||
ReminderJobsService,
|
||||
StorageHealthNotifierService,
|
||||
],
|
||||
exports: [NotificationsService, NotificationPreferencesService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -210,6 +210,13 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
replica_failure: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.replica_failure.title',
|
||||
textKey: 'notif.replica_failure.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/admin?tab=storage',
|
||||
},
|
||||
synology_session_cleared: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notifications.synologySessionCleared.title',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Lazy per-backend debounce (backfill/stats/notifications spec): the first
|
||||
* failure notifies immediately; later failures accumulate and the next one
|
||||
* past the window rides out as a summary carrying the suppressed count. No
|
||||
* timers — the summary is sent on the next failure, which is the only moment
|
||||
* anyone would care. Process-local, like the health ring itself.
|
||||
*/
|
||||
export class ReplicaFailureDebouncer {
|
||||
private readonly windows = new Map<string, { windowStart: number; suppressed: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly windowMs: number,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/** null = suppress; otherwise the suppressed-count to report with this send. */
|
||||
admit(backend: string): number | null {
|
||||
const now = this.now();
|
||||
const window = this.windows.get(backend);
|
||||
if (!window || now - window.windowStart > this.windowMs) {
|
||||
const suppressed = window?.suppressed ?? 0;
|
||||
this.windows.set(backend, { windowStart: now, suppressed: 0 });
|
||||
return suppressed;
|
||||
}
|
||||
window.suppressed += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { StorageEventsService } from '../storage/storage-events.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { ReplicaFailureDebouncer } from './replica-failure-debouncer';
|
||||
|
||||
const DEBOUNCE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Bridges storage replica failures into the admin notification channels.
|
||||
* Lives HERE (not in StorageModule) because NotificationsModule → AuthModule
|
||||
* → StorageModule — the reverse import would cycle. Subscribes at bootstrap;
|
||||
* the debouncer keeps an outage from becoming a notification storm.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageHealthNotifierService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(StorageHealthNotifierService.name);
|
||||
private readonly debouncer = new ReplicaFailureDebouncer(DEBOUNCE_WINDOW_MS);
|
||||
|
||||
constructor(
|
||||
private readonly events: StorageEventsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
this.events.onReplicaFailure((failure) => {
|
||||
const suppressed = this.debouncer.admit(failure.backend);
|
||||
if (suppressed === null) return;
|
||||
void this.notifications
|
||||
.send({
|
||||
event: 'replica_failure',
|
||||
actorId: null,
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {
|
||||
backend: failure.backend,
|
||||
key: failure.key,
|
||||
op: failure.op,
|
||||
error: failure.error,
|
||||
suppressed: String(suppressed),
|
||||
},
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.logger.error(
|
||||
`replica_failure notification failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4
|
||||
collab_message: { priority: 3, tags: ['speech_balloon'] },
|
||||
packing_tagged: { priority: 3, tags: ['luggage'] },
|
||||
version_available: { priority: 4, tags: ['package'] },
|
||||
replica_failure: { priority: 5, tags: ['warning', 'floppy_disk'] },
|
||||
synology_session_cleared: { priority: 3, tags: ['warning'] },
|
||||
};
|
||||
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ReplicaFailureDebouncer } from '../../../../src/nest/notifications/replica-failure-debouncer';
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
describe('ReplicaFailureDebouncer', () => {
|
||||
it('DEB-001 first failure per backend is admitted with suppressed=0', () => {
|
||||
let now = 1_000_000;
|
||||
const debouncer = new ReplicaFailureDebouncer(HOUR, () => now);
|
||||
expect(debouncer.admit('s3-bkp')).toBe(0);
|
||||
});
|
||||
|
||||
it('DEB-002 failures inside the window are suppressed; the next one after it carries the count', () => {
|
||||
let now = 1_000_000;
|
||||
const debouncer = new ReplicaFailureDebouncer(HOUR, () => now);
|
||||
expect(debouncer.admit('s3-bkp')).toBe(0);
|
||||
now += 1000;
|
||||
expect(debouncer.admit('s3-bkp')).toBeNull();
|
||||
expect(debouncer.admit('s3-bkp')).toBeNull();
|
||||
now += HOUR;
|
||||
expect(debouncer.admit('s3-bkp')).toBe(2); // the two suppressed ones ride the summary
|
||||
now += 1000;
|
||||
expect(debouncer.admit('s3-bkp')).toBeNull(); // window reset
|
||||
});
|
||||
|
||||
it('DEB-003 backends debounce independently', () => {
|
||||
let now = 1_000_000;
|
||||
const debouncer = new ReplicaFailureDebouncer(HOUR, () => now);
|
||||
expect(debouncer.admit('a')).toBe(0);
|
||||
expect(debouncer.admit('b')).toBe(0);
|
||||
expect(debouncer.admit('a')).toBeNull();
|
||||
expect(debouncer.admit('b')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { StorageEventsService } from '../../../../src/nest/storage/storage-events.service';
|
||||
import { StorageHealthNotifierService } from '../../../../src/nest/notifications/storage-health-notifier.service';
|
||||
import type { NotificationsService } from '../../../../src/nest/notifications/notifications.service';
|
||||
|
||||
describe('StorageHealthNotifierService', () => {
|
||||
it('NOTIF-001 subscribes on bootstrap and sends an admin-scoped replica_failure with params', () => {
|
||||
const events = new StorageEventsService();
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService);
|
||||
notifier.onApplicationBootstrap();
|
||||
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', at: 1 });
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
event: 'replica_failure',
|
||||
actorId: null,
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', suppressed: '0' },
|
||||
});
|
||||
// Second failure inside the window: suppressed, no second send.
|
||||
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/x.zip', op: 'put', error: 'timeout', at: 2 });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('NOTIF-002 a rejected send never throws into the emitter (write path stays safe)', () => {
|
||||
const events = new StorageEventsService();
|
||||
const send = vi.fn().mockRejectedValue(new Error('smtp down'));
|
||||
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService);
|
||||
notifier.onApplicationBootstrap();
|
||||
expect(() =>
|
||||
events.emitReplicaFailure({ backend: 'b', key: 'k', op: 'delete', error: 'e', at: 3 }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,12 @@ const ar: NotificationLocale = {
|
||||
title: 'إصدار TREK جديد متاح',
|
||||
body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'تمت إعادة تعيين جلسة Synology',
|
||||
body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
|
||||
'notif.version_available.title': 'إصدار جديد متاح',
|
||||
'notif.version_available.text': 'TREK {version} متاح الآن',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'عرض الرحلة',
|
||||
'notif.action.view_collab': 'عرض الرسائل',
|
||||
'notif.action.view_packing': 'عرض التعبئة',
|
||||
|
||||
@@ -52,6 +52,12 @@ const br: NotificationLocale = {
|
||||
title: 'Nova versão do TREK disponível',
|
||||
body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sessão Synology encerrada',
|
||||
body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
|
||||
'notif.version_available.title': 'Nova versão disponível',
|
||||
'notif.version_available.text': 'TREK {version} está disponível',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Ver viagem',
|
||||
'notif.action.view_collab': 'Ver mensagens',
|
||||
'notif.action.view_packing': 'Ver bagagem',
|
||||
|
||||
@@ -48,6 +48,12 @@ const ca: NotificationLocale = {
|
||||
title: 'Versió nova de TREK disponible',
|
||||
body: `TREK ${p.version} ja està disponible. Visita el panell d'administració per actualitzar.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sessió de Synology tancada',
|
||||
body: 'El teu compte o URL de Synology ha canviat. Has tancat la sessió a Synology Photos.',
|
||||
|
||||
@@ -25,6 +25,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} et va assignar a {category} a {trip}',
|
||||
'notif.version_available.title': 'Versió nova disponible',
|
||||
'notif.version_available.text': 'TREK {version} ja està disponible',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Mostra el viatge',
|
||||
'notif.action.view_collab': 'Mostra els missatges',
|
||||
'notif.action.view_packing': "Mostra l'equipatge",
|
||||
|
||||
@@ -52,6 +52,12 @@ const cs: NotificationLocale = {
|
||||
title: 'Nová verze TREK dostupná',
|
||||
body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Relace Synology byla zrušena',
|
||||
body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} vás přiřadil k {category} v {trip}',
|
||||
'notif.version_available.title': 'Nová verze dostupná',
|
||||
'notif.version_available.text': 'TREK {version} je nyní dostupný',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Zobrazit výlet',
|
||||
'notif.action.view_collab': 'Zobrazit zprávy',
|
||||
'notif.action.view_packing': 'Zobrazit balení',
|
||||
|
||||
@@ -52,6 +52,12 @@ const de: NotificationLocale = {
|
||||
title: 'Neue TREK-Version verfügbar',
|
||||
body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology-Sitzung beendet',
|
||||
body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen',
|
||||
'notif.version_available.title': 'Neue Version verfügbar',
|
||||
'notif.version_available.text': 'TREK {version} ist jetzt verfügbar',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Reise ansehen',
|
||||
'notif.action.view_collab': 'Nachrichten ansehen',
|
||||
'notif.action.view_packing': 'Packliste ansehen',
|
||||
|
||||
@@ -52,6 +52,12 @@ const en: NotificationLocale = {
|
||||
title: 'New TREK version available',
|
||||
body: `TREK ${p.version} is now available. Visit the admin panel to update.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology session cleared',
|
||||
body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
|
||||
'notif.version_available.title': 'New Version Available',
|
||||
'notif.version_available.text': 'TREK {version} is now available',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'View Trip',
|
||||
'notif.action.view_collab': 'View Messages',
|
||||
'notif.action.view_packing': 'View Packing',
|
||||
|
||||
@@ -52,6 +52,12 @@ const es: NotificationLocale = {
|
||||
title: 'Nueva versión de TREK disponible',
|
||||
body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sesión de Synology cerrada',
|
||||
body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}',
|
||||
'notif.version_available.title': 'Nueva versión disponible',
|
||||
'notif.version_available.text': 'TREK {version} ya está disponible',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Ver viaje',
|
||||
'notif.action.view_collab': 'Ver mensajes',
|
||||
'notif.action.view_packing': 'Ver equipaje',
|
||||
|
||||
@@ -33,6 +33,7 @@ export type NotificationEventKey =
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available'
|
||||
| 'replica_failure'
|
||||
| 'synology_session_cleared'
|
||||
| 'plugin_notification';
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ const fr: NotificationLocale = {
|
||||
title: 'Nouvelle version TREK disponible',
|
||||
body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Session Synology effacée',
|
||||
body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} vous a assigné à {category} dans {trip}',
|
||||
'notif.version_available.title': 'Nouvelle version disponible',
|
||||
'notif.version_available.text': 'TREK {version} est maintenant disponible',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Voir le voyage',
|
||||
'notif.action.view_collab': 'Voir les messages',
|
||||
'notif.action.view_packing': 'Voir les bagages',
|
||||
|
||||
@@ -52,6 +52,12 @@ const gr: NotificationLocale = {
|
||||
title: 'Νέα έκδοση TREK διαθέσιμη',
|
||||
body: `Η έκδοση TREK ${p.version} είναι τώρα διαθέσιμη. Επισκεφθείτε τον πίνακα διαχείρισης για να ενημερώσετε.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Η σύνδεση Synology τερματίστηκε',
|
||||
body: 'Ο λογαριασμός σας Synology ή το URL άλλαξε. Έχετε αποσυνδεθεί από το Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': 'Ο/Η {actor} σας ανέθεσε στο {category} του {trip}',
|
||||
'notif.version_available.title': 'Νέα Έκδοση Διαθέσιμη',
|
||||
'notif.version_available.text': 'Το TREK {version} είναι τώρα διαθέσιμο',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Προβολή Ταξιδιού',
|
||||
'notif.action.view_collab': 'Προβολή Μηνυμάτων',
|
||||
'notif.action.view_packing': 'Προβολή Αποσκευών',
|
||||
|
||||
@@ -52,6 +52,12 @@ const hu: NotificationLocale = {
|
||||
title: 'Új TREK verzió érhető el',
|
||||
body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology munkamenet törölve',
|
||||
body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} hozzárendelte Önt a {category} kategóriához a(z) {trip} utazásban',
|
||||
'notif.version_available.title': 'Új verzió elérhető',
|
||||
'notif.version_available.text': 'A TREK {version} elérhető',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Utazás megtekintése',
|
||||
'notif.action.view_collab': 'Üzenetek megtekintése',
|
||||
'notif.action.view_packing': 'Csomagolás megtekintése',
|
||||
|
||||
@@ -52,6 +52,12 @@ const id: NotificationLocale = {
|
||||
title: 'Versi TREK baru tersedia',
|
||||
body: `TREK ${p.version} sekarang tersedia. Kunjungi panel admin untuk memperbarui.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sesi Synology dihapus',
|
||||
body: 'Akun atau URL Synology Anda berubah. Anda telah keluar dari Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} menugaskanmu ke {category} di {trip}',
|
||||
'notif.version_available.title': 'Versi Baru Tersedia',
|
||||
'notif.version_available.text': 'TREK {version} kini tersedia',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Lihat Perjalanan',
|
||||
'notif.action.view_collab': 'Lihat Pesan',
|
||||
'notif.action.view_packing': 'Lihat Perlengkapan',
|
||||
|
||||
@@ -52,6 +52,12 @@ const it: NotificationLocale = {
|
||||
title: 'Nuova versione TREK disponibile',
|
||||
body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sessione Synology rimossa',
|
||||
body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} ti ha assegnato a {category} in {trip}',
|
||||
'notif.version_available.title': 'Nuova versione disponibile',
|
||||
'notif.version_available.text': 'TREK {version} è ora disponibile',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Vedi viaggio',
|
||||
'notif.action.view_collab': 'Vedi messaggi',
|
||||
'notif.action.view_packing': 'Vedi bagagli',
|
||||
|
||||
@@ -52,6 +52,12 @@ const ja: NotificationLocale = {
|
||||
title: '新しいTREKバージョンが利用可能',
|
||||
body: `TREK ${p.version}が利用可能になりました。管理パネルからアップデートしてください。`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synologyセッションがクリアされました',
|
||||
body: 'SynologyアカウントまたはURLが変更されました。Synology Photosからログアウトされました。',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor}が「{trip}」の{category}をあなたに割り当てました',
|
||||
'notif.version_available.title': '新しいバージョンがあります',
|
||||
'notif.version_available.text': 'TREK {version}が利用可能です',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': '旅行を見る',
|
||||
'notif.action.view_collab': 'メッセージを見る',
|
||||
'notif.action.view_packing': '持ち物を見る',
|
||||
|
||||
@@ -52,6 +52,12 @@ const ko: NotificationLocale = {
|
||||
title: '새 TREK 버전 사용 가능',
|
||||
body: `TREK ${p.version}을 사용할 수 있습니다. 관리자 패널에서 업데이트하세요.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology 세션이 초기화되었습니다',
|
||||
body: 'Synology 계정 또는 URL이 변경되었습니다. Synology Photos에서 로그아웃되었습니다.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor}이(가) {trip}의 {category}에 배정했습니다',
|
||||
'notif.version_available.title': '새 버전 사용 가능',
|
||||
'notif.version_available.text': 'TREK {version}이(가) 사용 가능합니다',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': '여행 보기',
|
||||
'notif.action.view_collab': '메시지 보기',
|
||||
'notif.action.view_packing': '짐 목록 보기',
|
||||
|
||||
@@ -52,6 +52,12 @@ const nl: NotificationLocale = {
|
||||
title: 'Nieuwe TREK-versie beschikbaar',
|
||||
body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology-sessie gewist',
|
||||
body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} heeft je toegewezen aan {category} in {trip}',
|
||||
'notif.version_available.title': 'Nieuwe versie beschikbaar',
|
||||
'notif.version_available.text': 'TREK {version} is nu beschikbaar',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Reis bekijken',
|
||||
'notif.action.view_collab': 'Berichten bekijken',
|
||||
'notif.action.view_packing': 'Paklijst bekijken',
|
||||
|
||||
@@ -52,6 +52,12 @@ const pl: NotificationLocale = {
|
||||
title: 'Nowa wersja TREK dostępna',
|
||||
body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sesja Synology wyczyszczona',
|
||||
body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} przypisał Cię do {category} w {trip}',
|
||||
'notif.version_available.title': 'Nowa wersja dostępna',
|
||||
'notif.version_available.text': 'TREK {version} jest teraz dostępny',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Zobacz podróż',
|
||||
'notif.action.view_collab': 'Zobacz wiadomości',
|
||||
'notif.action.view_packing': 'Zobacz pakowanie',
|
||||
|
||||
@@ -52,6 +52,12 @@ const ru: NotificationLocale = {
|
||||
title: 'Доступна новая версия TREK',
|
||||
body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Сессия Synology сброшена',
|
||||
body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} назначил вас в {category} в {trip}',
|
||||
'notif.version_available.title': 'Доступна новая версия',
|
||||
'notif.version_available.text': 'TREK {version} теперь доступен',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Открыть поездку',
|
||||
'notif.action.view_collab': 'Открыть сообщения',
|
||||
'notif.action.view_packing': 'Открыть упаковку',
|
||||
|
||||
@@ -52,6 +52,12 @@ const en: NotificationLocale = {
|
||||
title: 'Ny TREK version tillgänglig',
|
||||
body: `TREK ${p.version} är nu tillgänglig. Gå till adminpanelen för att uppdatera.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology session rensad',
|
||||
body: 'Ditt Synology-konto eller din webbadress har ändrats. Du har loggats ut från Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} har tilldelat dig {category} i {trip}',
|
||||
'notif.version_available.title': 'Ny version tillgänglig',
|
||||
'notif.version_available.text': 'TREK {version} finns nu tillgängligt',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Visa resan',
|
||||
'notif.action.view_collab': 'Visa meddelanden',
|
||||
'notif.action.view_packing': 'Visa förpackning',
|
||||
|
||||
@@ -52,6 +52,12 @@ const tr: NotificationLocale = {
|
||||
title: 'Yeni TREK sürümü mevcut',
|
||||
body: `TREK ${p.version} artık mevcut. Güncellemek için yönetici panelini ziyaret edin.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology oturumu temizlendi',
|
||||
body: 'Synology hesabınız veya URL değişti. Synology Photos oturumunuz kapatıldı.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} sizi {trip} içinde {category} kategorisine atadı',
|
||||
'notif.version_available.title': 'Yeni Sürüm Mevcut',
|
||||
'notif.version_available.text': 'TREK {version} artık kullanılabilir',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Seyahati Görüntüle',
|
||||
'notif.action.view_collab': 'Mesajları Görüntüle',
|
||||
'notif.action.view_packing': 'Paket listesini görüntüle',
|
||||
|
||||
@@ -52,6 +52,12 @@ const uk: NotificationLocale = {
|
||||
title: 'Доступна нова версія TREK',
|
||||
body: `TREK ${p.version} тепер доступний. Перейдіть до панелі адміністратора для оновлення.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Сеанс Synology скинуто',
|
||||
body: 'Ваш обліковий запис або URL Synology змінився. Ви вийшли з Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} призначив вас у {category} у {trip}',
|
||||
'notif.version_available.title': 'Доступна нова версія',
|
||||
'notif.version_available.text': 'TREK {version} тепер доступний',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Відкрити поїздку',
|
||||
'notif.action.view_collab': 'Відкрити повідомлення',
|
||||
'notif.action.view_packing': 'Відкрити пакування',
|
||||
|
||||
@@ -52,6 +52,12 @@ const en: NotificationLocale = {
|
||||
title: 'Đã có phiên bản TREK mới',
|
||||
body: `TREK ${p.version} có bản mới. Vui lòng truy cập bảng điều khiển quản trị để cập nhật.`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Đã xóa phiên Synology',
|
||||
body: 'Tài khoản Synology của bạn hoặc URL đã thay đổi. Bạn đã đăng xuất khỏi Synology Photos.',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} đã giao cho bạn {category} trong {trip}',
|
||||
'notif.version_available.title': 'Phiên bản mới có sẵn',
|
||||
'notif.version_available.text': 'TREK {version} hiện có sẵn',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': 'Xem chuyến đi',
|
||||
'notif.action.view_collab': 'Xem tin nhắn',
|
||||
'notif.action.view_packing': 'Xem đóng gói',
|
||||
|
||||
@@ -52,6 +52,12 @@ const zhTW: NotificationLocale = {
|
||||
title: '新版 TREK 可用',
|
||||
body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology 工作階段已清除',
|
||||
body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} 在 {trip} 中將 {category} 指派給你',
|
||||
'notif.version_available.title': '有新版本可用',
|
||||
'notif.version_available.text': 'TREK {version} 現已推出',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': '查看旅行',
|
||||
'notif.action.view_collab': '查看訊息',
|
||||
'notif.action.view_packing': '查看打包',
|
||||
|
||||
@@ -52,6 +52,12 @@ const zh: NotificationLocale = {
|
||||
title: '新版 TREK 可用',
|
||||
body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。`,
|
||||
}),
|
||||
replica_failure: (p) => ({
|
||||
title: 'Storage replica failure',
|
||||
body:
|
||||
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Synology 会话已清除',
|
||||
body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。',
|
||||
|
||||
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
|
||||
'notif.packing_tagged.text': '{actor} 将您分配到 {trip} 中的 {category}',
|
||||
'notif.version_available.title': '新版本可用',
|
||||
'notif.version_available.text': 'TREK {version} 现已可用',
|
||||
'notif.replica_failure.title': 'Storage replica failure',
|
||||
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
|
||||
'notif.action.view_trip': '查看旅行',
|
||||
'notif.action.view_collab': '查看消息',
|
||||
'notif.action.view_packing': '查看行李',
|
||||
|
||||
Reference in New Issue
Block a user