diff --git a/.gitignore b/.gitignore index 05627588..cb3e0b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ docs/k8s/secrets.local.yaml # generated api clients internal/guest/gateway/business/client/ +pkg/gateway/admin/client/ .agent diff --git a/Makefile b/Makefile index 2af58985..1364f53f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: build build-guest build-business build-auth build-migrator build-notifications build-outbox build-lambda -.PHONY: run-all run-guest run-business run-auth migrate-up +.PHONY: run-all run-guest run-business run-auth run-notifications run-outbox migrate-up .PHONY: test test-cover tidy clean .PHONY: docs docs-guest docs-business docs-admin-auth .PHONY: generate generate-guest-business-client @@ -43,6 +43,12 @@ run-business: build-business run-auth: build-auth ./bin/admin-auth-api +run-notifications: build-notifications + ./bin/notifications-service + +run-outbox: build-outbox + ./bin/outbox-worker + migrate-up: build-migrator ./bin/migrator diff --git a/build/compose.infra.yaml b/build/compose.infra.yaml index 01fa920e..9cef3b59 100644 --- a/build/compose.infra.yaml +++ b/build/compose.infra.yaml @@ -68,9 +68,29 @@ services: timeout: 5s retries: 5 + localstack: + image: localstack/localstack:3 + container_name: share-bite-localstack + ports: + - "4566:4566" + environment: + SERVICES: sns,sqs + DEFAULT_REGION: us-east-2 + AWS_DEFAULT_REGION: us-east-2 + volumes: + - localstack-data:/var/lib/localstack + networks: + - share-bite-net + healthcheck: + test: ["CMD-SHELL", "awslocal sns list-topics >/dev/null 2>&1 && awslocal sqs list-queues >/dev/null 2>&1"] + interval: 5s + timeout: 5s + retries: 10 + volumes: pg_data: pgadmin_data: + localstack-data: redis-data: driver: local diff --git a/cmd/admin-auth-api/main.go b/cmd/admin-auth-api/main.go index 8a2f6b6c..21238474 100644 --- a/cmd/admin-auth-api/main.go +++ b/cmd/admin-auth-api/main.go @@ -4,10 +4,8 @@ import ( "context" "errors" "net/http" - "time" "github.com/gin-gonic/gin" - "github.com/sony/gobreaker" businessclient "github.com/ua-academy-projects/share-bite/internal/admin-auth/adapter/business" guestclient "github.com/ua-academy-projects/share-bite/internal/admin-auth/adapter/guest" apperr "github.com/ua-academy-projects/share-bite/internal/admin-auth/error" @@ -17,9 +15,6 @@ import ( mcphttp "github.com/ua-academy-projects/share-bite/internal/admin-auth/handler/mcp" "github.com/ua-academy-projects/share-bite/internal/admin-auth/worker" "github.com/ua-academy-projects/share-bite/internal/config/env" - "github.com/ua-academy-projects/share-bite/pkg/notification" - "github.com/ua-academy-projects/share-bite/pkg/redis" - "github.com/ua-academy-projects/share-bite/pkg/resilience" "go.uber.org/zap" authhttp "github.com/ua-academy-projects/share-bite/internal/admin-auth/handler/auth" @@ -62,11 +57,6 @@ func main() { logger.Fatal(ctx, "load google oauth config: ", err) } - redisCfg, err := env.NewRedisConfig() - if err != nil { - logger.Fatal(ctx, "load redis config: ", err) - } - router := gin.New() router.Use(gin.Recovery()) router.Use(pkgmw.RequestID()) @@ -95,56 +85,6 @@ func main() { return nil }) - rdb, err := redis.NewClient( - redisCfg.Addr(), - redisCfg.Password(), - redisCfg.DB(), - redisCfg.TLS(), - ) - if err != nil { - logger.Fatal(ctx, "new redis client: ", err) - } - closer.Add(func(ctx context.Context) error { - return rdb.Close() - }) - - notificationResiliencePolicy := resilience.Policy{ - RetryConfig: resilience.RetryConfig{ - InitialInterval: 10 * time.Millisecond, - RandomizationFactor: 0.2, - Multiplier: 2.0, - MaxInterval: 200 * time.Millisecond, - MaxElapsedTime: 1500 * time.Millisecond, - }, - Breaker: resilience.NewCircuitBreaker(resilience.CircuitBreakerConfig{ - Name: "admin-redis-pub", - MaxRequests: 1, - Interval: 10 * time.Second, - Timeout: 5 * time.Second, - ReadyToTrip: func(counts gobreaker.Counts) bool { - return counts.ConsecutiveFailures >= 20 - }, - OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) { - logger.WarnKV(ctx, "redis circuit breaker state changed", - "name", name, - "from", from.String(), - "to", to.String(), - ) - }, - IsSuccessful: func(err error) bool { - if err == nil || errors.Is(err, context.Canceled) { - return true - } - return redis.IsPermanentRedisError(err) - }, - }), - RetryNotify: func(err error, nextRetryIn time.Duration) { - logger.Debugf(ctx, "redis publish retry scheduled in %v: %v", nextRetryIn, err) - }, - } - - broker := notification.NewBroker(rdb, notification.WithPublishPolicy(notificationResiliencePolicy)) - tokenManager := jwt.NewTokenManager( cfg.JwtToken.AccessTokenSecretKey(), cfg.JwtToken.RefreshTokenSecretKey(), @@ -176,7 +116,7 @@ func main() { customerClient := guestclient.NewClient(client) businessClient := businessclient.NewClient(client) - adminSvc := adminsvc.NewService(adminRepo, userRepo, customerClient, businessClient, broker, txManager) + adminSvc := adminsvc.NewService(adminRepo, userRepo, customerClient, businessClient, outboxWriter, txManager) adminHandler := adminhttp.NewHandler(adminSvc) mcpSvc := mcpsvc.NewMCPPermissionService(adminRepo) diff --git a/cmd/outbox-worker/main.go b/cmd/outbox-worker/main.go index e10f207d..9dfdff11 100644 --- a/cmd/outbox-worker/main.go +++ b/cmd/outbox-worker/main.go @@ -45,7 +45,10 @@ func main() { logger.Fatal(ctx, "OUTBOX_SNS_TOPIC_ARN is required") } - snsPub, err := outboxpkg.NewSNSPublisher(ctx, topicArn) + // Optional: point SNS at a custom endpoint (e.g. LocalStack) for local dev. + snsEndpoint := config.GetSecret("OUTBOX_SNS_ENDPOINT_URL") + + snsPub, err := outboxpkg.NewSNSPublisher(ctx, topicArn, snsEndpoint) if err != nil { logger.Fatal(ctx, "new sns publisher:", err) } diff --git a/frontend/src/App.css b/frontend/src/App.css index 9ed2d357..1dde7dcb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,6 +5,36 @@ @custom-variant dark (&:is(.dark *)); +/* + * Map the design tokens below into Tailwind's color namespace so shadcn + * utilities (bg-popover, bg-muted, text-muted-foreground, border-border, …) + * are generated. Uses `inline` + var() so the .dark overrides keep working. + */ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + :root { --background: #F9F7F2; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c288224..8b9e0404 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,8 @@ import { ProfileCreatePage } from "@/pages/guest/UserProfile/ProfileCreatePage"; import { CreatePost } from "@/pages/guest/CreatePost/CreatePost"; import { AdminUsersPage } from "@/pages/guest/Admin/AdminUsersPage"; import { AdminUserDetailPage } from "@/pages/guest/Admin/AdminUserDetailPage"; +import { AdminStatisticsPage } from "@/pages/guest/Admin/AdminStatisticsPage"; +import { AdminPendingBusinessesPage } from "@/pages/guest/Admin/AdminPendingBusinessesPage"; import { ForbiddenPage } from "@/pages/guest/Forbidden/ForbiddenPage"; import { isUserRole } from "@/utils/auth"; @@ -166,6 +168,22 @@ function App() { } /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 11883166..5e2bc026 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,6 +15,10 @@ import type { FullUserDetails, AdminUsersParams, CollectionItem, + PlatformStatistics, + PaginatedPendingBusinesses, + ReviewBusinessStatus, + PaginationParams, } from "@/types/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; @@ -552,6 +556,31 @@ export const apiClient = { return res.data; }, + adminGetStatistics: async () => { + const res = await apiRoot.get("/admin/statistics"); + return res.data; + }, + + adminGetPendingBusinesses: async (params: PaginationParams = {}) => { + const res = await apiRoot.get( + "/admin/businesses/pending", + { params } + ); + return res.data; + }, + + adminReviewBusiness: async ( + orgUnitId: number, + status: ReviewBusinessStatus, + comment?: string + ) => { + const res = await apiRoot.patch<{ message: string }>( + `/admin/businesses/${orgUnitId}/review`, + { status, ...(comment ? { comment } : {}) } + ); + return res.data; + }, + updateUserStatus: async (userId: string, status: string) => { const res = await apiRoot.put<{ message: string }>( `/users/${userId}/status`, diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 0a8d89d2..c814fbc1 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -31,7 +31,7 @@ function mapNotification(raw: Record): NotificationItem { entityID: String(raw.entityID ?? raw.entity_id ?? ""), metadata, createdAt: String(raw.createdAt ?? raw.created_at ?? new Date().toISOString()), - read: Boolean(raw.read), + read: Boolean(raw.isRead ?? raw.is_read ?? raw.read), message, }; } diff --git a/frontend/src/components/Notifications/NotificationBell.tsx b/frontend/src/components/Notifications/NotificationBell.tsx index 33817878..9e6e0f36 100644 --- a/frontend/src/components/Notifications/NotificationBell.tsx +++ b/frontend/src/components/Notifications/NotificationBell.tsx @@ -1,7 +1,7 @@ import { Link } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { Bell } from "lucide-react"; -import { fetchNotifications } from "@/api/notifications"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bell, CheckCheck } from "lucide-react"; +import { fetchNotifications, markNotificationsRead } from "@/api/notifications"; import { Button } from "@/components/ui/button"; import { Popover, @@ -20,6 +20,8 @@ type NotificationBellProps = { export function NotificationBell({ variant = "default" }: NotificationBellProps) { const token = localStorage.getItem("token"); + const queryClient = useQueryClient(); + const { data: notifications = [] } = useQuery({ queryKey: ["notifications"], queryFn: () => fetchNotifications(token!, 20), @@ -28,7 +30,13 @@ export function NotificationBell({ variant = "default" }: NotificationBellProps) enabled: !!token, }); - const unreadCount = notifications.filter((n) => !n.read).length; + const markRead = useMutation({ + mutationFn: (ids: string[]) => markNotificationsRead(token!, ids), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notifications"] }), + }); + + const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id); + const unreadCount = unreadIds.length; const formatMessage = (n: (typeof notifications)[0]) => n.message || @@ -78,9 +86,23 @@ export function NotificationBell({ variant = "default" }: NotificationBellProps) Notifications - - {unreadCount} new - +
+ + {unreadCount} new + + {unreadCount > 0 && ( + + )} +
diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/Sidebar.tsx index 7dfcfe84..ae974998 100644 --- a/frontend/src/components/ui/Sidebar.tsx +++ b/frontend/src/components/ui/Sidebar.tsx @@ -6,6 +6,7 @@ import { NotificationBell } from "@/components/Notifications/NotificationBell"; import { LogoutDialog } from "@/components/LogoutDialog"; import { useCurrentCustomer } from "@/hooks/useCurrentCustomer"; import { useOnboardingStatus } from "@/hooks/useOnboardingStatus"; +import { useRealtimeNotifications } from "@/hooks/useRealtimeNotifications"; import { getBusinessOrgId, getTokenRole, @@ -30,6 +31,7 @@ export function Sidebar() { const token = localStorage.getItem("token"); const { data: customer } = useCurrentCustomer(); useOnboardingStatus(!!token); + useRealtimeNotifications(); const [logoutOpen, setLogoutOpen] = useState(false); const businessOrgId = getBusinessOrgId(); @@ -183,9 +185,15 @@ export function Sidebar() { {isAdminOrModerator() ? ( <> - + Admin Users + + Statistics + + + Verify Businesses + ) : null} diff --git a/frontend/src/hooks/useRealtimeNotifications.ts b/frontend/src/hooks/useRealtimeNotifications.ts new file mode 100644 index 00000000..fe3e1f3b --- /dev/null +++ b/frontend/src/hooks/useRealtimeNotifications.ts @@ -0,0 +1,50 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { buildNotificationsStreamUrl } from "@/api/notifications"; + +/** + * Opens an SSE connection to the notifications stream and refreshes the + * ["notifications"] query cache whenever a new notification is pushed, so the + * bell badge and the notifications list update live β€” no page reload needed. + * + * Mount once for an authenticated session (e.g. in the Sidebar). + */ +export function useRealtimeNotifications() { + const queryClient = useQueryClient(); + const token = localStorage.getItem("token"); + + useEffect(() => { + if (!token) return; + + let es: EventSource | null = null; + let reconnectTimer: number | null = null; + let closed = false; + + const connect = () => { + es = new EventSource(buildNotificationsStreamUrl(token)); + + // Default ("message") events carry notifications; "ping" heartbeats are + // named events and intentionally ignored here. + es.onmessage = () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }; + + es.onerror = () => { + es?.close(); + if (closed || reconnectTimer) return; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connect(); + }, 5000); + }; + }; + + connect(); + + return () => { + closed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + es?.close(); + }; + }, [queryClient, token]); +} diff --git a/frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx b/frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx new file mode 100644 index 00000000..32d62ec6 --- /dev/null +++ b/frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx @@ -0,0 +1,365 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Building2, CheckCircle2, Hash, Loader2, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/api/client"; +import type { PendingBusinessListItem } from "@/types/api"; +import { PageLayout } from "@/components/layout/PageLayout"; +import { pageBtnPrimary, pageEmpty, pageLoader } from "@/components/layout/pageStyles"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 10; + +function businessInitial(name: string) { + return name.trim().charAt(0).toUpperCase() || "?"; +} + +function statusBadgeClass(status: string) { + switch (status.toLowerCase()) { + case "verified": + return "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300"; + case "rejected": + return "border-red-500/40 bg-red-500/10 text-red-500 dark:text-red-400"; + default: + return "border-[#FFD700]/40 bg-[#FFD700]/10 text-[#FFD700]"; + } +} + +export function AdminPendingBusinessesPage() { + const navigate = useNavigate(); + const [businesses, setBusinesses] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [page, setPage] = useState(0); + const [actingId, setActingId] = useState(null); + + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectComment, setRejectComment] = useState(""); + + useEffect(() => { + let mounted = true; + const load = async () => { + setLoading(true); + setError(""); + try { + const data = await apiClient.adminGetPendingBusinesses({ + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }); + if (mounted) { + setBusinesses(data.items ?? []); + setTotalCount(data.total_count ?? 0); + } + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + if (mounted) { + setError( + e?.response?.data?.error || e?.message || "Failed to load pending businesses." + ); + } + } finally { + if (mounted) setLoading(false); + } + }; + void load(); + return () => { + mounted = false; + }; + }, [page]); + + const removeFromList = (id: number) => { + setBusinesses((prev) => prev.filter((b) => b.id !== id)); + setTotalCount((prev) => Math.max(0, prev - 1)); + }; + + const handleApprove = async (business: PendingBusinessListItem) => { + setActingId(business.id); + try { + await apiClient.adminReviewBusiness(business.id, "verified"); + toast.success(`"${business.name}" has been verified.`); + removeFromList(business.id); + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + toast.error(e?.response?.data?.error || e?.message || "Failed to verify business."); + } finally { + setActingId(null); + } + }; + + const handleReject = async () => { + if (!rejectTarget) return; + const comment = rejectComment.trim(); + if (!comment) return; + setActingId(rejectTarget.id); + try { + await apiClient.adminReviewBusiness(rejectTarget.id, "rejected", comment); + toast.success(`"${rejectTarget.name}" has been rejected.`); + removeFromList(rejectTarget.id); + setRejectTarget(null); + setRejectComment(""); + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + toast.error(e?.response?.data?.error || e?.message || "Failed to reject business."); + } finally { + setActingId(null); + } + }; + + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const currentPage = page + 1; + + return ( + +
+

+ Admin β€” Verify Businesses{" "} + 🏒 +

+

+ Review and approve business accounts awaiting verification +

+
+ +
+ + {totalCount} pending + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +
+ +
+ ) : businesses.length === 0 ? ( +
+

+ No businesses awaiting review +

+

All caught up β€” nothing to verify right now.

+
+ ) : ( +
+ {businesses.map((business) => { + const busy = actingId === business.id; + return ( + navigate(`/venue/${business.id}`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate(`/venue/${business.id}`); + } + }} + aria-label={`Open ${business.name} business page`} + className="cursor-pointer overflow-hidden rounded-3xl border border-gray-200 bg-white py-0 shadow-sm ring-0 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 dark:border-[#2f5e50] dark:bg-[#163d32]" + > + + {/* Identity header */} +
+
+ {business.avatar ? ( + + ) : ( + businessInitial(business.name) + )} +
+
+
+

+ {business.name} +

+ + {business.status} + +
+
+ + + Org #{business.id} + + {business.org_account_id ? ( + + + {business.org_account_id} + + ) : null} +
+
+
+ + {/* Body */} +
+
+

+ About +

+ {business.description ? ( +

+ {business.description} +

+ ) : ( +

No description provided.

+ )} +
+ +
+ + +
+
+
+
+ ); + })} +
+ )} + + {totalPages > 1 ? ( +
+ +

+ Page {currentPage} of {totalPages} +

+ +
+ ) : null} + + { + if (!open) { + setRejectTarget(null); + setRejectComment(""); + } + }} + > + + + Reject business + + Rejecting {rejectTarget?.name}{" "} + requires a reason. It will be recorded with the review. + + +
+