diff --git a/Caddyfile b/Caddyfile index 2bf9bdc..64af54d 100644 --- a/Caddyfile +++ b/Caddyfile @@ -2,24 +2,22 @@ root * /usr/share/caddy -# Serve static assets with correct MIME types - no fallback @static path /assets/* /icons/* /manifest.webmanifest /service-worker.js handle @static { file_server } -# SPA fallback - only for navigation requests handle { try_files {path} /index.html file_server } 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-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" - Permissions-Policy "camera=(), microphone=(), geolocation=()" X-XSS-Protection "0" Cross-Origin-Opener-Policy "same-origin" + Service-Worker-Allowed "/" } diff --git a/deploy.log b/deploy.log new file mode 100644 index 0000000..3abc464 --- /dev/null +++ b/deploy.log @@ -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 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..4f277ff --- /dev/null +++ b/deploy.sh @@ -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 diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 107202f..c722e80 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,7 +1,7 @@ { - "name": "Школьное питание", - "short_name": "Школьное питание", - "description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.", + "name": "SuperSam Доставка", + "short_name": "SuperSam", + "description": "Панель управления доставкой стройматериалов с уведомлениями.", "start_url": "/dashboard", "scope": "/", "display": "standalone", diff --git a/public/service-worker.js b/public/service-worker.js index 1d6ea31..3593d27 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,8 +1,8 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v1"; - const RUNTIME_CACHE = "construction-delivery-runtime-v1"; + const STATIC_CACHE = "construction-delivery-static-v2"; + const RUNTIME_CACHE = "construction-delivery-runtime-v2"; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"]; self.addEventListener("install", (event) => { @@ -80,3 +80,46 @@ if (!isLocalhost) { self.addEventListener("install", (event) => self.skipWaiting()); 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); + }), + ); +}); diff --git a/src/components/UI/Icons.jsx b/src/components/UI/Icons.jsx new file mode 100644 index 0000000..614ad12 --- /dev/null +++ b/src/components/UI/Icons.jsx @@ -0,0 +1,50 @@ +import React from "react"; + +const Icon = ({ children, className = "" }) => ( + + {children} + +); + +export const Bell = (props) => ( + + + + +); + +export const Settings = (props) => ( + + + + +); + +export const Check = (props) => ( + + + +); + +export const CheckCheck = (props) => ( + + + + +); + +export const X = (props) => ( + + + + +); diff --git a/src/components/notifications/NotificationBell.jsx b/src/components/notifications/NotificationBell.jsx new file mode 100644 index 0000000..da7d0ac --- /dev/null +++ b/src/components/notifications/NotificationBell.jsx @@ -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 ( +
+ + + {isOpen && ( +
+ +
+

Уведомления

+
+ {unreadCount > 0 && ( + + )} + {onOpenSettings && ( + + )} + +
+
+ +
+ {notifications.length === 0 ? ( +
+ Нет уведомлений +
+ ) : ( + notifications.map((notif) => ( +
!notif.read && onMarkAsRead(notif.id)} + > + + {TYPE_ICONS[notif.type] || "📦"} + +
+
+

+ {notif.title} +

+ + {formatTimeAgo(notif.created_at)} + +
+ {notif.body && ( +

+ {notif.body} +

+ )} +
+ {!notif.read && ( +
+ )} +
+ )) + )} +
+ +
+ )} +
+ ); +} diff --git a/src/components/notifications/NotificationSettings.jsx b/src/components/notifications/NotificationSettings.jsx new file mode 100644 index 0000000..aae93de --- /dev/null +++ b/src/components/notifications/NotificationSettings.jsx @@ -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 ( +
+
+ {onBack && ( + + )} +

Настройки уведомлений

+
+ + {/* Push toggle */} + +
+
+ +
+

Push-уведомления

+

+ {isSupported + ? isSubscribed + ? "Включены — вы получаете уведомления на устройстве" + : "Выкл — нажмите чтобы включить" + : "Не поддерживаются в этом браузере"} +

+
+
+ {isSupported && ( + + )} +
+
+ + {/* Type preferences */} + +

+ + Что уведомлять +

+
+ {NOTIF_TYPES.map(({ key, label, description }) => ( + + ))} +
+
+
+ ); +} diff --git a/src/hooks/useNotifications.js b/src/hooks/useNotifications.js new file mode 100644 index 0000000..8ae7f67 --- /dev/null +++ b/src/hooks/useNotifications.js @@ -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 }; +} diff --git a/src/hooks/usePushNotifications.js b/src/hooks/usePushNotifications.js new file mode 100644 index 0000000..cece5a6 --- /dev/null +++ b/src/hooks/usePushNotifications.js @@ -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 }; +} diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index 643e2c9..8630722 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -4,6 +4,8 @@ import { Badge } from "../components/UI/Badge"; import { Button } from "../components/UI/Button"; import { Panel } from "../components/UI/Panel"; import { ThemeToggle } from "../components/UI/ThemeToggle"; +import { NotificationBell } from "../components/notifications/NotificationBell"; +import { NotificationSettings } from "../components/notifications/NotificationSettings"; export const AppShell = ({ user, @@ -14,9 +16,27 @@ export const AppShell = ({ activeSection, onSectionChange, sectionMeta, + notifications = [], + unreadCount = 0, + onMarkNotificationRead, + onMarkAllNotificationsRead, children, }) => { const shouldShowMobileNav = !isGuideOpen && navItems.length > 1; + const [showNotifSettings, setShowNotifSettings] = React.useState(false); + + if (showNotifSettings) { + return ( +
+
+ setShowNotifSettings(false)} + /> +
+
+ ); + } return (
@@ -75,6 +95,13 @@ export const AppShell = ({

+ setShowNotifSettings(true)} + /> {onOpenGuide ? (
+ setShowNotifSettings(true)} + />
{user.name}
{ROLE_LABELS[user.role]}
diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 3a561d4..c994e27 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -6,6 +6,7 @@ import { OrdersTable } from "../components/orders/OrdersTable"; import { Panel } from "../components/UI/Panel"; import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel"; import { useAuth } from "../context/AuthContext"; +import { useNotifications } from "../hooks/useNotifications"; import { useOrderGroups } from "../hooks/useOrderGroups"; import { AppShell } from "../layouts/AppShell"; @@ -34,6 +35,14 @@ export const DashboardPage = () => { const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager; const [activeSection, setActiveSection] = React.useState(section.key); + const { + notifications, + unreadCount, + isLoading: notifLoading, + markAsRead: markNotificationRead, + markAllAsRead: markAllNotificationsRead, + } = useNotifications(user?.id); + const { orderGroups, allOrderGroups, @@ -52,7 +61,7 @@ export const DashboardPage = () => { }, [section.key]); const openGroupPage = React.useCallback((groupId) => { - navigate(`/dashboard/group/${groupId}`); + navigate("/dashboard/group/" + groupId); }, [navigate]); const navItems = [ @@ -72,7 +81,7 @@ export const DashboardPage = () => { const isGuideOpen = activeSection === "guide"; if (!user) { - return ; + return ; } const renderManagerWorkspace = () => ( @@ -130,6 +139,10 @@ export const DashboardPage = () => { activeSection={activeSection} onSectionChange={setActiveSection} sectionMeta={activeSectionMeta} + notifications={notifications} + unreadCount={unreadCount} + onMarkNotificationRead={markNotificationRead} + onMarkAllNotificationsRead={markAllNotificationsRead} > {isLoading ? ( diff --git a/webhook_listener.py b/webhook_listener.py new file mode 100644 index 0000000..5149600 --- /dev/null +++ b/webhook_listener.py @@ -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()