feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)
- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen) - Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal) - Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse - Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
browserPermissionState,
|
||||
claimNotification,
|
||||
isTauriWebview,
|
||||
remindersToNotify,
|
||||
requestBrowserPermissionOnce,
|
||||
showReminderNotification,
|
||||
withNotifyLock,
|
||||
} from './reminder-notify';
|
||||
import type { Reminder } from './reminders-api';
|
||||
|
||||
const NOW = Date.parse('2026-09-29T12:00:00.000Z');
|
||||
|
||||
function reminder(id: string, dueAt: string): Reminder {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
description: '',
|
||||
dueAt,
|
||||
emailEnabled: false,
|
||||
createdAt: dueAt,
|
||||
updatedAt: dueAt,
|
||||
};
|
||||
}
|
||||
|
||||
function installNotification(permission: NotificationPermission) {
|
||||
const ctor = vi.fn();
|
||||
const requestPermission = vi.fn(async () => 'granted' as NotificationPermission);
|
||||
Object.assign(ctor, { permission, requestPermission });
|
||||
vi.stubGlobal('Notification', ctor);
|
||||
return { ctor, requestPermission };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function installTauri() {
|
||||
const invoke = vi.fn(async () => undefined);
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke };
|
||||
return invoke;
|
||||
}
|
||||
|
||||
describe('claimNotification', () => {
|
||||
it('gibt beim ersten Mal true, danach false (gleicher Schluessel)', () => {
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('nach einem Verschieben (neues dueAt) ist es ein neuer Schluessel', () => {
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('entfernt Eintraege aelter als 7 Tage', () => {
|
||||
claimNotification('alt', NOW - 8 * 24 * 3600_000);
|
||||
claimNotification('neu', NOW);
|
||||
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
||||
expect(Object.keys(stored)).toEqual(['neu']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindersToNotify', () => {
|
||||
it('nimmt nur Faellige der letzten 24 Stunden (nie vorher, D-02; E-03)', () => {
|
||||
const list = [
|
||||
reminder('zukunft', new Date(NOW + 1000).toISOString()),
|
||||
reminder('genau', new Date(NOW).toISOString()),
|
||||
reminder('vor-1h', new Date(NOW - 3600_000).toISOString()),
|
||||
reminder('vor-25h', new Date(NOW - 25 * 3600_000).toISOString()),
|
||||
];
|
||||
expect(remindersToNotify(list, NOW).map((r) => r.id)).toEqual(['genau', 'vor-1h']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showReminderNotification', () => {
|
||||
it('in Tauri: invoke plugin:notification|notify mit genau diesem Koerper', async () => {
|
||||
const invoke = installTauri();
|
||||
expect(isTauriWebview()).toBe(true);
|
||||
await showReminderNotification({ title: 'T', body: 'B', tag: 'x' });
|
||||
expect(invoke).toHaveBeenCalledWith('plugin:notification|notify', {
|
||||
options: { title: 'T', body: 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
it('in Tauri: ein Fehler des Plugins wird nur als Warnung ausgegeben', async () => {
|
||||
const invoke = installTauri();
|
||||
invoke.mockRejectedValue(new Error('nope'));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('im Browser: nur mit Erlaubnis granted', async () => {
|
||||
const { ctor } = installNotification('granted');
|
||||
await showReminderNotification({ title: 'T', body: 'B', tag: 'tag1' });
|
||||
expect(ctor).toHaveBeenCalledWith('T', { body: 'B', tag: 'tag1' });
|
||||
});
|
||||
|
||||
it.each(['default', 'denied'] as const)('im Browser: bei %s keine Meldung', async (p) => {
|
||||
const { ctor } = installNotification(p);
|
||||
await showReminderNotification({ title: 'T', body: 'B', tag: 't' });
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestBrowserPermissionOnce', () => {
|
||||
it('fragt hoechstens einmal (D-04)', () => {
|
||||
const { requestPermission } = installNotification('default');
|
||||
requestBrowserPermissionOnce();
|
||||
requestBrowserPermissionOnce();
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fragt nie in Tauri', () => {
|
||||
installTauri();
|
||||
const { requestPermission } = installNotification('default');
|
||||
requestBrowserPermissionOnce();
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fragt nicht, wenn die Entscheidung schon getroffen ist', () => {
|
||||
const { requestPermission } = installNotification('denied');
|
||||
requestBrowserPermissionOnce();
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Web-Benachrichtigungen passiert nichts', () => {
|
||||
vi.stubGlobal('Notification', undefined);
|
||||
expect(() => requestBrowserPermissionOnce()).not.toThrow();
|
||||
expect(browserPermissionState()).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserPermissionState', () => {
|
||||
it('meldet desktop in Tauri, sonst die Browser-Erlaubnis', () => {
|
||||
installNotification('denied');
|
||||
expect(browserPermissionState()).toBe('denied');
|
||||
installTauri();
|
||||
expect(browserPermissionState()).toBe('desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withNotifyLock', () => {
|
||||
it('ruft fn direkt, wenn es keine Web-Sperren gibt', async () => {
|
||||
const fn = vi.fn(() => 42);
|
||||
await expect(withNotifyLock(fn)).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it('nutzt navigator.locks, wenn vorhanden', async () => {
|
||||
const request = vi.fn(async (_name: string, cb: () => unknown) => cb());
|
||||
vi.stubGlobal('navigator', { ...navigator, locks: { request } });
|
||||
await expect(withNotifyLock(() => 'x')).resolves.toBe('x');
|
||||
expect(request).toHaveBeenCalledWith('tessera-reminder-notify', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Benachrichtigungen fuer Erinnerungen (quick-260929-if2) — reine Funktionen
|
||||
* ohne React, damit Melder und Kachel sie gemeinsam nutzen und testen koennen.
|
||||
*
|
||||
* Zwei Wege, je nach Umgebung:
|
||||
* - Desktop-App (Tauri-Webview): der Aufruf geht DIREKT an das Benachrichtigungs-
|
||||
* Plugin (`plugin:notification|notify`). Die Rust-Seite erteilt dafuer zur
|
||||
* Laufzeit genau diese eine Berechtigung fuer die gespeicherte Serveradresse
|
||||
* (E-01, `grant_server_notifications` in apps/desktop/src-tauri/src/lib.rs).
|
||||
* - Browser: die Web-Benachrichtigung (`new Notification(...)`), nur mit
|
||||
* erteilter Erlaubnis.
|
||||
*
|
||||
* WARUM DER TAURI-WEG NIE `Notification.permission` LIEST (E-01): das Plugin
|
||||
* ersetzt `window.Notification` in der Webview durch einen Ersatz, der
|
||||
* denselben IPC-Aufruf macht. Dieser Ersatz meldet unter Windows bei JEDEM
|
||||
* Seitenaufruf `permission = "denied"`, bis einmal `requestPermission()`
|
||||
* gelaufen ist. Wer darauf hoeren wuerde, unterdrueckte im Desktop-Client
|
||||
* jede Meldung. Deshalb ruft der Tauri-Zweig `invoke` direkt und fragt keine
|
||||
* Erlaubnis ab.
|
||||
*/
|
||||
|
||||
import type { Reminder } from './reminders-api';
|
||||
|
||||
const PERMISSION_ASKED_KEY = 'tessera.reminders.permissionAsked';
|
||||
const NOTIFIED_KEY = 'tessera.reminders.notified';
|
||||
const LOCK_NAME = 'tessera-reminder-notify';
|
||||
|
||||
/** E-03: so lange nach der Faelligkeit wird noch nachgemeldet (ein Client, der spaet oeffnet). */
|
||||
export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
||||
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type BrowserPermissionState =
|
||||
| 'desktop'
|
||||
| 'granted'
|
||||
| 'default'
|
||||
| 'denied'
|
||||
| 'unsupported';
|
||||
|
||||
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
/** Die `invoke`-Funktion der Tauri-Webview, sonst `null` (Browser). */
|
||||
function tauriInvoke(): TauriInvoke | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const internals: unknown = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
if (typeof internals !== 'object' || internals === null) return null;
|
||||
const invoke: unknown = (internals as Record<string, unknown>).invoke;
|
||||
return typeof invoke === 'function' ? (invoke as TauriInvoke) : null;
|
||||
}
|
||||
|
||||
/** `true` in der Desktop-App (Tauri-Webview), `false` im normalen Browser. */
|
||||
export function isTauriWebview(): boolean {
|
||||
return tauriInvoke() !== null;
|
||||
}
|
||||
|
||||
function hasWebNotifications(): boolean {
|
||||
return typeof Notification !== 'undefined';
|
||||
}
|
||||
|
||||
/** Zustand der Benachrichtigungs-Erlaubnis; in der Desktop-App immer `desktop`. */
|
||||
export function browserPermissionState(): BrowserPermissionState {
|
||||
if (isTauriWebview()) return 'desktop';
|
||||
if (!hasWebNotifications()) return 'unsupported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(key) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt die Browser-Erlaubnis HOECHSTENS EINMAL (D-04). Aufzurufen aus einer
|
||||
* Nutzerhandlung (erstes Anlegen im Widget), nie beim Laden der Seite. Nichts
|
||||
* passiert in der Desktop-App, ohne Web-Benachrichtigungen, bei bereits
|
||||
* getroffener Entscheidung oder wenn die Frage schon einmal gestellt wurde.
|
||||
*/
|
||||
export function requestBrowserPermissionOnce(): void {
|
||||
if (isTauriWebview() || !hasWebNotifications()) return;
|
||||
if (Notification.permission !== 'default') return;
|
||||
if (readFlag(PERMISSION_ASKED_KEY)) return;
|
||||
try {
|
||||
window.localStorage.setItem(PERMISSION_ASKED_KEY, '1');
|
||||
} catch {
|
||||
/* ohne Speicher lieber fragen als nie */
|
||||
}
|
||||
try {
|
||||
void Notification.requestPermission();
|
||||
} catch {
|
||||
/* aeltere Browser: kein Versprechen, keine Folge */
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReminderNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** Zeigt eine Benachrichtigung — Desktop ueber das Plugin, Browser nur mit Erlaubnis. */
|
||||
export async function showReminderNotification(n: ReminderNotification): Promise<void> {
|
||||
const invoke = tauriInvoke();
|
||||
if (invoke) {
|
||||
try {
|
||||
await invoke('plugin:notification|notify', { options: { title: n.title, body: n.body } });
|
||||
} catch (err) {
|
||||
console.warn('[reminders] Desktop-Benachrichtigung fehlgeschlagen', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!hasWebNotifications() || Notification.permission !== 'granted') return;
|
||||
try {
|
||||
new Notification(n.title, { body: n.body, tag: n.tag });
|
||||
} catch (err) {
|
||||
console.warn('[reminders] Browser-Benachrichtigung fehlgeschlagen', err);
|
||||
}
|
||||
}
|
||||
|
||||
function readNotified(): Record<string, number> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(NOTIFIED_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'number') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
||||
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
||||
* Tabs desselben Browsers hinweg (gemeinsamer localStorage). Nach einem
|
||||
* Verschieben aendert sich `dueAt` und damit der Schluessel. Eintraege
|
||||
* aelter als 7 Tage werden dabei entfernt.
|
||||
*/
|
||||
export function claimNotification(key: string, nowMs: number): boolean {
|
||||
const record = readNotified();
|
||||
for (const [k, at] of Object.entries(record)) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
||||
}
|
||||
const first = !(key in record);
|
||||
if (first) record[key] = nowMs;
|
||||
try {
|
||||
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Fuehrt `fn` unter einer Web-Sperre aus (mehrere Tabs nacheinander), sonst direkt. */
|
||||
export async function withNotifyLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
||||
if (locks && typeof locks.request === 'function') {
|
||||
return locks.request(LOCK_NAME, async () => fn());
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnerungen, die JETZT gemeldet werden sollen: faellig (`dueAt <= now`,
|
||||
* D-02: nie vorher) und nicht aelter als 24 Stunden (E-03).
|
||||
*/
|
||||
export function remindersToNotify(reminders: Reminder[], nowMs: number): Reminder[] {
|
||||
return reminders.filter((r) => {
|
||||
const due = new Date(r.dueAt).getTime();
|
||||
return due <= nowMs && due > nowMs - CATCH_UP_WINDOW_MS;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Zeit-Hilfen der Erinnerungen (quick-260929-if2) — alles in Ortszeit des
|
||||
* Benutzers, reine Funktionen ohne React.
|
||||
*/
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_RE = /^\d{2}:\d{2}$/;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* Datums- und Zeitfeld (Ortszeit, `type="date"`/`type="time"`) als ISO-Zeitpunkt.
|
||||
* `null` bei unvollstaendiger oder ungueltiger Eingabe.
|
||||
*/
|
||||
export function localInputsToIso(date: string, time: string): string | null {
|
||||
if (!DATE_RE.test(date) || !TIME_RE.test(time)) return null;
|
||||
const d = new Date(`${date}T${time}`);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
/** Vorbelegung fuer eine neue Erinnerung: heute, die naechste volle Stunde. */
|
||||
export function defaultNewReminderInputs(now: Date): { date: string; time: string } {
|
||||
const next = new Date(now);
|
||||
next.setHours(next.getHours() + 1, 0, 0, 0);
|
||||
return {
|
||||
date: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`,
|
||||
time: `${pad(next.getHours())}:${pad(next.getMinutes())}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReminderRequestError, createReminder, listReminders } from './reminders-api';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
|
||||
|
||||
describe('reminders-api', () => {
|
||||
it('listReminders ruft GET /reminders mit Cookie-Anmeldung', async () => {
|
||||
fetchMock.mockResolvedValue(ok([]));
|
||||
await expect(listReminders()).resolves.toEqual([]);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders$/);
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('createReminder schickt POST mit JSON-Koerper', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
|
||||
await createReminder({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders$/);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('ein Status ausserhalb 2xx wirft ReminderRequestError mit diesem Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });
|
||||
await expect(listReminders()).rejects.toMatchObject({ status: 401 });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
|
||||
const err = await createReminder({ title: 'a', dueAt: 'x' }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ReminderRequestError);
|
||||
expect(err.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Erinnerungen — API-Klient (quick-260929-if2). Spiegelt den
|
||||
* `RemindersController` der API. Alle Aufrufe mit `credentials: 'include'`
|
||||
* (Cookie-Anmeldung), Muster `favorites-api.ts`.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/** Ereignis nach jeder erfolgreichen Aenderung — der globale Melder laedt daraufhin neu. */
|
||||
export const REMINDERS_CHANGED_EVENT = 'tessera:reminders-changed';
|
||||
|
||||
/** Eine Erinnerung; Zeitangaben als ISO-Zeichenketten (so liefert sie die API). */
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueAt: string;
|
||||
emailEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Ein abgewiesener Aufruf; `status` ist der HTTP-Status (401, 404, 409 ...). */
|
||||
export class ReminderRequestError extends Error {
|
||||
public readonly status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(`Reminder request failed (${status})`);
|
||||
this.name = 'ReminderRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${path}`, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new ReminderRequestError(res.status);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function jsonInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||
export function listReminders(): Promise<Reminder[]> {
|
||||
return request<Reminder[]>('/reminders');
|
||||
}
|
||||
|
||||
export interface ReminderInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** ISO-8601-Zeitpunkt. */
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export function createReminder(input: ReminderInput): Promise<Reminder> {
|
||||
return request<Reminder>('/reminders', jsonInit('POST', input));
|
||||
}
|
||||
Reference in New Issue
Block a user