feat: push notifications + PWA notifications UI
- VAPID keys generated for web push - push_subscriptions, notifications, notification_preferences tables - DB triggers: auto-notify on order status change, driver assignment - Edge functions: send-push-notification, subscribe-push, unsubscribe-push - Frontend: NotificationBell, NotificationSettings components - usePushNotifications hook (subscribe/unsubscribe push) - useNotifications hook (fetch + realtime + mark read) - Service worker: push event handler + notification click - AppShell: notification bell in header - DashboardPage: wired notifications - manifest.webmanifest: name updated to SuperSam - Caddyfile: CSP allows wss:// + fcm push
This commit is contained in:
parent
43c5f75055
commit
dc9d7de60f
|
|
@ -2,24 +2,22 @@
|
||||||
|
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
|
|
||||||
# Serve static assets with correct MIME types - no fallback
|
|
||||||
@static path /assets/* /icons/* /manifest.webmanifest /service-worker.js
|
@static path /assets/* /icons/* /manifest.webmanifest /service-worker.js
|
||||||
handle @static {
|
handle @static {
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback - only for navigation requests
|
|
||||||
handle {
|
handle {
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru; frame-ancestors 'none'; form-action 'self'; base-uri 'self'"
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru wss://supa.supersamsev.ru https://fcm.googleapis.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
|
||||||
X-XSS-Protection "0"
|
X-XSS-Protection "0"
|
||||||
Cross-Origin-Opener-Policy "same-origin"
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Service-Worker-Allowed "/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
|
||||||
|
--- Deploy triggered for refs/heads/main ---
|
||||||
|
[2026-05-20 13:43:18] Deploy starting...
|
||||||
|
HEAD is now at 6244749 feat: unify manual_required status and driver label
|
||||||
|
[2026-05-20 13:43:18] No changes, skipping rebuild.
|
||||||
|
From http://10.0.2.2:3000/mihail/supersam
|
||||||
|
* branch main -> FETCH_HEAD
|
||||||
|
Exit code: 0
|
||||||
|
|
||||||
|
=== Deploy dev @ 2026-05-20 13:54:33.475857 ===
|
||||||
|
[2026-05-20 13:54:33] Dev deploy starting...
|
||||||
|
HEAD is now at 488e478 Merge pull request 'fix(delivery): simplify public choice flow' (#2) from codex/delivery-rpc-deploy into main
|
||||||
|
[2026-05-20 13:54:33] No changes, skipping rebuild.
|
||||||
|
From http://10.0.2.2:3000/mihail/supersam
|
||||||
|
* branch dev -> FETCH_HEAD
|
||||||
|
Exit code: 0
|
||||||
|
|
||||||
|
=== Deploy main @ 2026-05-20 14:01:43.629298 ===
|
||||||
|
[2026-05-20 14:01:43] Deploy starting...
|
||||||
|
HEAD is now at cfb4110 fix: Caddyfile static assets + update package-lock
|
||||||
|
[2026-05-20 14:01:43] No changes, skipping rebuild.
|
||||||
|
From http://10.0.2.2:3000/mihail/supersam
|
||||||
|
* branch main -> FETCH_HEAD
|
||||||
|
Exit code: 0
|
||||||
|
|
||||||
|
=== Deploy main @ 2026-05-20 14:05:34.915793 ===
|
||||||
|
[2026-05-20 14:05:34] Deploy starting...
|
||||||
|
HEAD is now at 5a5636c fix: use npm install instead of npm ci for build reliability
|
||||||
|
[2026-05-20 14:05:35] No changes, skipping rebuild.
|
||||||
|
From http://10.0.2.2:3000/mihail/supersam
|
||||||
|
* branch main -> FETCH_HEAD
|
||||||
|
Exit code: 0
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd /opt/supersam
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy starting..."
|
||||||
|
|
||||||
|
# Pull latest from main
|
||||||
|
git fetch origin main
|
||||||
|
BEFORE=$(git rev-parse HEAD)
|
||||||
|
git reset --hard origin/main
|
||||||
|
AFTER=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
if [ "$BEFORE" = "$AFTER" ]; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] No changes, skipping rebuild."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Updated: ${BEFORE:0:7} -> ${AFTER:0:7}"
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker compose -f docker-compose.app.yml up -d --build
|
||||||
|
|
||||||
|
# Wait for container to be healthy
|
||||||
|
sleep 3
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q 'supersam-app'; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy complete. Container running."
|
||||||
|
else
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Container not running after deploy!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Школьное питание",
|
"name": "SuperSam Доставка",
|
||||||
"short_name": "Школьное питание",
|
"short_name": "SuperSam",
|
||||||
"description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.",
|
"description": "Панель управления доставкой стройматериалов с уведомлениями.",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v1";
|
const STATIC_CACHE = "construction-delivery-static-v2";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v2";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
@ -80,3 +80,46 @@ if (!isLocalhost) {
|
||||||
self.addEventListener("install", (event) => self.skipWaiting());
|
self.addEventListener("install", (event) => self.skipWaiting());
|
||||||
self.addEventListener("activate", (event) => self.clients.claim());
|
self.addEventListener("activate", (event) => self.clients.claim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push notification handler
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
data = { title: "Уведомление", body: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.title || "Уведомление";
|
||||||
|
const options = {
|
||||||
|
body: data.body || "",
|
||||||
|
icon: data.icon || "/icons/icon-192.svg",
|
||||||
|
badge: data.badge || "/icons/icon-192.svg",
|
||||||
|
data: data.data || {},
|
||||||
|
tag: data.tag || "default",
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
requireInteraction: data.requireInteraction || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const clickData = event.notification.data || {};
|
||||||
|
const targetUrl = clickData.order_id
|
||||||
|
? "/dashboard/group/" + clickData.order_id
|
||||||
|
: "/dashboard";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes("/dashboard") && "focus" in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const Icon = ({ children, className = "" }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Bell = (props) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Settings = (props) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Check = (props) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckCheck = (props) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M18 6 7 17l-5-5" />
|
||||||
|
<path d="m22 6-11 11-2-2" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const X = (props) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Bell, Check, CheckCheck, Settings, X } from "../UI/Icons";
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
driver_assigned: "🚚",
|
||||||
|
driver_unassigned: "📤",
|
||||||
|
order_status_change: "📦",
|
||||||
|
delivery_problem: "⚠️",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
driver_assigned: "Назначение водителя",
|
||||||
|
driver_unassigned: "Снятие водителя",
|
||||||
|
order_status_change: "Изменение статуса",
|
||||||
|
delivery_problem: "Проблема доставки",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimeAgo(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMin < 1) return "сейчас";
|
||||||
|
if (diffMin < 60) return `${diffMin} мин`;
|
||||||
|
const diffH = Math.floor(diffMin / 60);
|
||||||
|
if (diffH < 24) return `${diffH} ч`;
|
||||||
|
const diffD = Math.floor(diffH / 24);
|
||||||
|
return `${diffD} д`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onMarkAllAsRead, onOpenSettings }) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const bellRef = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (bellRef.current && !bellRef.current.contains(e.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={bellRef}>
|
||||||
|
<button
|
||||||
|
className="relative flex h-9 w-9 items-center justify-center rounded-full transition hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount})` : ""}`}
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 w-80 sm:w-96">
|
||||||
|
<Panel className="max-h-[480px] overflow-hidden p-0 shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">Уведомления</h3>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-xs text-[var(--color-accent)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={onMarkAllAsRead}
|
||||||
|
title="Прочитать все"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={() => { onOpenSettings(); setIsOpen(false); }}
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto" style={{ maxHeight: "400px" }}>
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
|
||||||
|
Нет уведомлений
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((notif) => (
|
||||||
|
<div
|
||||||
|
key={notif.id}
|
||||||
|
className={`flex gap-3 border-b border-[var(--color-border)] px-4 py-3 transition hover:bg-[var(--color-surface-strong)] ${
|
||||||
|
!notif.read ? "bg-[var(--color-accent-soft)]/30" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => !notif.read && onMarkAsRead(notif.id)}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 text-base">
|
||||||
|
{TYPE_ICONS[notif.type] || "📦"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className={`text-sm ${!notif.read ? "font-semibold" : "font-normal"}`}>
|
||||||
|
{notif.title}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{formatTimeAgo(notif.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{notif.body && (
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--color-text-muted)] line-clamp-2">
|
||||||
|
{notif.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!notif.read && (
|
||||||
|
<div className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--color-accent)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useNotificationPreferences } from "../../hooks/useNotifications";
|
||||||
|
import { usePushNotifications } from "../../hooks/usePushNotifications";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Bell, Settings } from "../UI/Icons";
|
||||||
|
|
||||||
|
const NOTIF_TYPES = [
|
||||||
|
{ key: "order_status_change", label: "Изменение статуса заказа", description: "Уведомления когда меняется статус заказа" },
|
||||||
|
{ key: "driver_assigned", label: "Назначение водителя", description: "Уведомления когда вам назначается заказ" },
|
||||||
|
{ key: "delivery_problem", label: "Проблемы с доставкой", description: "Уведомления об отменах и проблемах" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function NotificationSettings({ userId, onBack }) {
|
||||||
|
const { prefs, isLoading: prefsLoading, updatePref } = useNotificationPreferences(userId);
|
||||||
|
const { isSupported, isSubscribed, isLoading: pushLoading, subscribe, unsubscribe } = usePushNotifications(userId);
|
||||||
|
|
||||||
|
const loading = prefsLoading || pushLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push toggle */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bell className="h-5 w-5 text-[var(--color-accent)]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Push-уведомления</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{isSupported
|
||||||
|
? isSubscribed
|
||||||
|
? "Включены — вы получаете уведомления на устройстве"
|
||||||
|
: "Выкл — нажмите чтобы включить"
|
||||||
|
: "Не поддерживаются в этом браузере"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSupported && (
|
||||||
|
<button
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
isSubscribed ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
||||||
|
}`}
|
||||||
|
disabled={pushLoading}
|
||||||
|
onClick={isSubscribed ? unsubscribe : subscribe}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||||
|
isSubscribed ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Type preferences */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Что уведомлять
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{NOTIF_TYPES.map(({ key, label, description }) => (
|
||||||
|
<label key={key} className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||||
|
prefs[key] ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
||||||
|
}`}
|
||||||
|
disabled={prefsLoading}
|
||||||
|
onClick={() => updatePref(key, !prefs[key])}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||||
|
prefs[key] ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import React from "react";
|
||||||
|
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
|
export function useNotifications(userId) {
|
||||||
|
const [notifications, setNotifications] = React.useState([]);
|
||||||
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
const fetchNotifications = React.useCallback(async () => {
|
||||||
|
if (!hasSupabaseConfig || !userId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("notifications")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
setNotifications(data);
|
||||||
|
setUnreadCount(data.filter((n) => !n.read).length);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
// Realtime subscription
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasSupabaseConfig || !userId) return;
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`notifications:${userId}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "INSERT",
|
||||||
|
schema: "public",
|
||||||
|
table: "notifications",
|
||||||
|
filter: `user_id=eq.${userId}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const newNotif = payload.new;
|
||||||
|
setNotifications((prev) => [newNotif, ...prev].slice(0, 50));
|
||||||
|
setUnreadCount((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "UPDATE",
|
||||||
|
schema: "public",
|
||||||
|
table: "notifications",
|
||||||
|
filter: `user_id=eq.${userId}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const updated = payload.new;
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === updated.id ? updated : n))
|
||||||
|
);
|
||||||
|
setUnreadCount((prev) =>
|
||||||
|
Math.max(0, prev + (updated.read ? -1 : 0) + (prev === 0 && !updated.read ? 1 : 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
// Fix unread count: always recalculate from notifications
|
||||||
|
React.useEffect(() => {
|
||||||
|
setUnreadCount(notifications.filter((n) => !n.read).length);
|
||||||
|
}, [notifications]);
|
||||||
|
|
||||||
|
const markAsRead = React.useCallback(
|
||||||
|
async (notificationId) => {
|
||||||
|
if (!hasSupabaseConfig) return;
|
||||||
|
await supabase
|
||||||
|
.from("notifications")
|
||||||
|
.update({ read: true })
|
||||||
|
.eq("id", notificationId);
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const markAllAsRead = React.useCallback(async () => {
|
||||||
|
if (!hasSupabaseConfig || !userId) return;
|
||||||
|
await supabase
|
||||||
|
.from("notifications")
|
||||||
|
.update({ read: true })
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("read", false);
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
isLoading,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
refresh: fetchNotifications,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationPreferences(userId) {
|
||||||
|
const [prefs, setPrefs] = React.useState({
|
||||||
|
push_enabled: true,
|
||||||
|
order_status_change: true,
|
||||||
|
driver_assigned: true,
|
||||||
|
delivery_problem: true,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasSupabaseConfig || !userId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
supabase
|
||||||
|
.from("notification_preferences")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.single()
|
||||||
|
.then(({ data, error }) => {
|
||||||
|
if (data) {
|
||||||
|
setPrefs({
|
||||||
|
push_enabled: data.push_enabled,
|
||||||
|
order_status_change: data.order_status_change,
|
||||||
|
driver_assigned: data.driver_assigned,
|
||||||
|
delivery_problem: data.delivery_problem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const updatePref = React.useCallback(
|
||||||
|
async (key, value) => {
|
||||||
|
if (!hasSupabaseConfig || !userId) return;
|
||||||
|
setPrefs((prev) => ({ ...prev, [key]: value }));
|
||||||
|
await supabase
|
||||||
|
.from("notification_preferences")
|
||||||
|
.upsert({ user_id: userId, [key]: value, updated_at: new Date().toISOString() });
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { prefs, isLoading, updatePref };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React from "react";
|
||||||
|
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = "42A-yHJUYiqQ6Ev9GSVHe3fiOqXeBpvHoQ5ts6pnVGgzvI5lD4CoLBtRMoIGzBq7as3_S409nXtxTmDUt_Tn6Q";
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
return Uint8Array.from(rawData, (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeUserToPush(userId) {
|
||||||
|
if (!hasSupabaseConfig || !userId) return null;
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
|
console.warn("Push notifications not supported");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
try {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Push subscription failed:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) return null;
|
||||||
|
|
||||||
|
const res = await fetch(`${supabase.supabaseUrl}/functions/v1/subscribe-push`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Failed to save push subscription:", await res.text());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeUserFromPush() {
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!subscription) return;
|
||||||
|
|
||||||
|
const endpoint = subscription.endpoint;
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (session?.access_token) {
|
||||||
|
await fetch(`${supabase.supabaseUrl}/functions/v1/unsubscribe-push`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePushNotifications(userId) {
|
||||||
|
const [isSupported, setIsSupported] = React.useState(false);
|
||||||
|
const [isSubscribed, setIsSubscribed] = React.useState(false);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||||
|
setIsSupported(supported);
|
||||||
|
if (!supported) return;
|
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then((reg) => {
|
||||||
|
reg.pushManager.getSubscription().then((sub) => {
|
||||||
|
setIsSubscribed(!!sub);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const subscribe = React.useCallback(async () => {
|
||||||
|
if (!isSupported || !userId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
setIsSubscribed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sub = await subscribeUserToPush(userId);
|
||||||
|
setIsSubscribed(!!sub);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isSupported, userId]);
|
||||||
|
|
||||||
|
const unsubscribe = React.useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await unsubscribeUserFromPush();
|
||||||
|
setIsSubscribed(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isSupported, isSubscribed, isLoading, subscribe, unsubscribe };
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import { Badge } from "../components/UI/Badge";
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { ThemeToggle } from "../components/UI/ThemeToggle";
|
import { ThemeToggle } from "../components/UI/ThemeToggle";
|
||||||
|
import { NotificationBell } from "../components/notifications/NotificationBell";
|
||||||
|
import { NotificationSettings } from "../components/notifications/NotificationSettings";
|
||||||
|
|
||||||
export const AppShell = ({
|
export const AppShell = ({
|
||||||
user,
|
user,
|
||||||
|
|
@ -14,9 +16,27 @@ export const AppShell = ({
|
||||||
activeSection,
|
activeSection,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
sectionMeta,
|
sectionMeta,
|
||||||
|
notifications = [],
|
||||||
|
unreadCount = 0,
|
||||||
|
onMarkNotificationRead,
|
||||||
|
onMarkAllNotificationsRead,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||||
|
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||||
|
|
||||||
|
if (showNotifSettings) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<NotificationSettings
|
||||||
|
userId={user?.id}
|
||||||
|
onBack={() => setShowNotifSettings(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
|
@ -75,6 +95,13 @@ export const AppShell = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
|
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
|
||||||
|
<NotificationBell
|
||||||
|
notifications={notifications}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onMarkAsRead={onMarkNotificationRead}
|
||||||
|
onMarkAllAsRead={onMarkAllNotificationsRead}
|
||||||
|
onOpenSettings={() => setShowNotifSettings(true)}
|
||||||
|
/>
|
||||||
{onOpenGuide ? (
|
{onOpenGuide ? (
|
||||||
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
{isGuideOpen ? "Назад" : "?"}
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
|
|
@ -102,6 +129,13 @@ export const AppShell = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<NotificationBell
|
||||||
|
notifications={notifications}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onMarkAsRead={onMarkNotificationRead}
|
||||||
|
onMarkAllAsRead={onMarkAllNotificationsRead}
|
||||||
|
onOpenSettings={() => setShowNotifSettings(true)}
|
||||||
|
/>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm font-medium">{user.name}</div>
|
<div className="text-sm font-medium">{user.name}</div>
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>
|
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useNotifications } from "../hooks/useNotifications";
|
||||||
import { useOrderGroups } from "../hooks/useOrderGroups";
|
import { useOrderGroups } from "../hooks/useOrderGroups";
|
||||||
import { AppShell } from "../layouts/AppShell";
|
import { AppShell } from "../layouts/AppShell";
|
||||||
|
|
||||||
|
|
@ -34,6 +35,14 @@ export const DashboardPage = () => {
|
||||||
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
||||||
const [activeSection, setActiveSection] = React.useState(section.key);
|
const [activeSection, setActiveSection] = React.useState(section.key);
|
||||||
|
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
isLoading: notifLoading,
|
||||||
|
markAsRead: markNotificationRead,
|
||||||
|
markAllAsRead: markAllNotificationsRead,
|
||||||
|
} = useNotifications(user?.id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
orderGroups,
|
orderGroups,
|
||||||
allOrderGroups,
|
allOrderGroups,
|
||||||
|
|
@ -52,7 +61,7 @@ export const DashboardPage = () => {
|
||||||
}, [section.key]);
|
}, [section.key]);
|
||||||
|
|
||||||
const openGroupPage = React.useCallback((groupId) => {
|
const openGroupPage = React.useCallback((groupId) => {
|
||||||
navigate(`/dashboard/group/${groupId}`);
|
navigate("/dashboard/group/" + groupId);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|
@ -72,7 +81,7 @@ export const DashboardPage = () => {
|
||||||
const isGuideOpen = activeSection === "guide";
|
const isGuideOpen = activeSection === "guide";
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderManagerWorkspace = () => (
|
const renderManagerWorkspace = () => (
|
||||||
|
|
@ -130,6 +139,10 @@ export const DashboardPage = () => {
|
||||||
activeSection={activeSection}
|
activeSection={activeSection}
|
||||||
onSectionChange={setActiveSection}
|
onSectionChange={setActiveSection}
|
||||||
sectionMeta={activeSectionMeta}
|
sectionMeta={activeSectionMeta}
|
||||||
|
notifications={notifications}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onMarkNotificationRead={markNotificationRead}
|
||||||
|
onMarkAllNotificationsRead={markAllNotificationsRead}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Webhook listener: Gitea push → auto-deploy main (prod) and dev."""
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import os
|
||||||
|
|
||||||
|
PORT = 9876
|
||||||
|
LOG_FILE = "/opt/supersam/deploy.log"
|
||||||
|
|
||||||
|
DEPLOYMENTS = {
|
||||||
|
"refs/heads/main": {
|
||||||
|
"script": "/opt/supersam/deploy.sh",
|
||||||
|
"dir": "/opt/supersam",
|
||||||
|
"branch": "main",
|
||||||
|
},
|
||||||
|
"refs/heads/dev": {
|
||||||
|
"script": "/opt/supersam-dev/deploy.sh",
|
||||||
|
"dir": "/opt/supersam-dev",
|
||||||
|
"branch": "dev",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_deploy(ref, config):
|
||||||
|
script = config["script"]
|
||||||
|
workdir = config["dir"]
|
||||||
|
branch = config["branch"]
|
||||||
|
log = open(LOG_FILE, "a")
|
||||||
|
log.write(f"\n=== Deploy {branch} @ {__import__('datetime').datetime.now()} ===\n")
|
||||||
|
log.flush()
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", script],
|
||||||
|
capture_output=True, text=True, timeout=300,
|
||||||
|
cwd=workdir,
|
||||||
|
)
|
||||||
|
log.write(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
log.write(result.stderr)
|
||||||
|
log.write(f"Exit code: {result.returncode}\n")
|
||||||
|
log.close()
|
||||||
|
|
||||||
|
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/webhook/deploy":
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"Invalid JSON")
|
||||||
|
return
|
||||||
|
|
||||||
|
ref = payload.get("ref", "")
|
||||||
|
config = DEPLOYMENTS.get(ref)
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"Skipping push to {ref}".encode())
|
||||||
|
return
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run_deploy, args=(ref, config))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"Deploy triggered for {ref}".encode())
|
||||||
|
print(f"[webhook] Deploy triggered for {ref}")
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
print(f"[webhook] {args[0]}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = http.server.HTTPServer(("0.0.0.0", PORT), WebhookHandler)
|
||||||
|
print(f"[webhook] Listening on 0.0.0.0:{PORT}")
|
||||||
|
server.serve_forever()
|
||||||
Loading…
Reference in New Issue