Уведомления
-
+
+ {pushState !== 'unsupported' && (
+
+ )}
+
+
{isEmpty ? (
diff --git a/src/food-market.web/src/lib/webPush.ts b/src/food-market.web/src/lib/webPush.ts
new file mode 100644
index 0000000..b0bc9ce
--- /dev/null
+++ b/src/food-market.web/src/lib/webPush.ts
@@ -0,0 +1,77 @@
+import { api } from './api'
+
+/**
+ * Web Push (VAPID) на фронте: запрос публичного ключа, подписка через
+ * существующий service worker, регистрация подписки на сервере. Доставка —
+ * из фонового джоба уведомлений об отрицательных остатках. Работает в Chrome/
+ * Firefox/Edge и в iOS Safari для PWA, добавленного на домашний экран.
+ */
+
+export function isWebPushSupported(): boolean {
+ return typeof window !== 'undefined'
+ && 'serviceWorker' in navigator
+ && 'PushManager' in window
+ && 'Notification' in window
+}
+
+export type WebPushState = 'unsupported' | 'denied' | 'subscribed' | 'default'
+
+export async function getWebPushState(): Promise {
+ if (!isWebPushSupported()) return 'unsupported'
+ if (Notification.permission === 'denied') return 'denied'
+ try {
+ const reg = await navigator.serviceWorker.getRegistration()
+ const sub = await reg?.pushManager.getSubscription()
+ if (sub) return 'subscribed'
+ } catch { /* нет регистрации — ниже вернём default */ }
+ return 'default'
+}
+
+function urlBase64ToUint8Array(base64: string): Uint8Array {
+ const padding = '='.repeat((4 - (base64.length % 4)) % 4)
+ const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
+ const raw = atob(b64)
+ const out = new Uint8Array(raw.length)
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i)
+ return out
+}
+
+/** Включить push: разрешение → подписка в браузере → регистрация на сервере.
+ * Возвращает итоговое состояние. */
+export async function enableWebPush(): Promise {
+ if (!isWebPushSupported()) return 'unsupported'
+ const perm = await Notification.requestPermission()
+ if (perm !== 'granted') return perm === 'denied' ? 'denied' : 'default'
+
+ const reg = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register('/sw.js'))
+ await navigator.serviceWorker.ready
+
+ const { publicKey } = (await api.get<{ publicKey: string }>('/api/web-push/public-key')).data
+ let sub = await reg.pushManager.getSubscription()
+ if (!sub) {
+ sub = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(publicKey) as unknown as BufferSource,
+ })
+ }
+ const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } }
+ await api.post('/api/web-push/subscribe', {
+ endpoint: json.endpoint,
+ keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
+ })
+ return 'subscribed'
+}
+
+/** Отключить push: снять подписку в браузере и на сервере. */
+export async function disableWebPush(): Promise {
+ if (!isWebPushSupported()) return 'unsupported'
+ try {
+ const reg = await navigator.serviceWorker.getRegistration()
+ const sub = await reg?.pushManager.getSubscription()
+ if (sub) {
+ await api.post('/api/web-push/unsubscribe', { endpoint: sub.endpoint }).catch(() => {})
+ await sub.unsubscribe()
+ }
+ } catch { /* already gone */ }
+ return 'default'
+}
diff --git a/tests/food-market.IntegrationTests/NegativeStockTests.cs b/tests/food-market.IntegrationTests/NegativeStockTests.cs
index dc060c4..4793e87 100644
--- a/tests/food-market.IntegrationTests/NegativeStockTests.cs
+++ b/tests/food-market.IntegrationTests/NegativeStockTests.cs
@@ -159,4 +159,33 @@ public async Task Notify_job_creates_persistent_notification_for_admin_and_marks
(await api.Http.PostAsJsonAsync("/api/notifications/read-all", new { })).EnsureSuccessStatusCode();
(await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0);
}
+
+ [Fact]
+ public async Task WebPush_public_key_generated_and_subscription_roundtrips()
+ {
+ var api = new ApiActor(_factory.CreateClient());
+ await api.SignupAndLoginAsync($"wp-{Guid.NewGuid():N}");
+
+ // Публичный VAPID-ключ генерируется при первом обращении и стабилен.
+ var k1 = (await api.GetJsonAsync("/api/web-push/public-key")).GetProperty("publicKey").GetString();
+ k1.Should().NotBeNullOrEmpty();
+ var k2 = (await api.GetJsonAsync("/api/web-push/public-key")).GetProperty("publicKey").GetString();
+ k2.Should().Be(k1, "ключ хранится в platform_settings, не перегенерируется");
+
+ // Подписка идемпотентна по endpoint.
+ var sub = new { endpoint = "https://push.example.com/" + Guid.NewGuid().ToString("N"),
+ keys = new { p256dh = "BBindkeyp256dhExampleValue", auth = "authSecretExample" } };
+ (await api.Http.PostAsJsonAsync("/api/web-push/subscribe", sub)).StatusCode
+ .Should().Be(System.Net.HttpStatusCode.NoContent);
+ (await api.Http.PostAsJsonAsync("/api/web-push/subscribe", sub)).StatusCode
+ .Should().Be(System.Net.HttpStatusCode.NoContent);
+
+ // Невалидная подписка → 400.
+ (await api.Http.PostAsJsonAsync("/api/web-push/subscribe", new { endpoint = "", keys = new { p256dh = "", auth = "" } })).StatusCode
+ .Should().Be(System.Net.HttpStatusCode.BadRequest);
+
+ // Снятие подписки.
+ (await api.Http.PostAsJsonAsync("/api/web-push/unsubscribe", new { endpoint = sub.endpoint })).StatusCode
+ .Should().Be(System.Net.HttpStatusCode.NoContent);
+ }
}