diff --git a/frontend/README.md b/frontend/README.md index c8ed730e..c5dd1b7e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -20,7 +20,7 @@ go run ./cmd/notifications-service Run the SPA: ```bash -cd business-frontend +cd frontend npm install npm run dev ``` diff --git a/frontend/index.html b/frontend/index.html index 7172aec4..677d1a4a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - business-frontend + Share Bite
diff --git a/frontend/package.json b/frontend/package.json index 2790e7e4..89d197ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "business-frontend", + "name": "share-bite-frontend", "private": true, "version": "0.0.0", "type": "module", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5e2bc026..9c287255 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -330,7 +330,8 @@ export const apiClient = { })); }, - createPost: async (data: CreatePostInput) => { + createPost: async (data: CreatePostInput, token?: string) => { + const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined; const createFormData = new FormData(); createFormData.append("venueId", data.venueId.toString()); createFormData.append("text", data.text); @@ -338,9 +339,13 @@ export const apiClient = { if (data.images?.length) { data.images.forEach((img) => createFormData.append("images", img)); } + if (data.invitedCustomerIds?.length) { + data.invitedCustomerIds.forEach((id) => createFormData.append("invitedCustomerIds", id)); + } const createRes = await guestApi.post<{ post: RawPost }>( "/posts/", - createFormData + createFormData, + config ); const newPostId = createRes.data.post.id; const patchFormData = new FormData(); @@ -351,7 +356,8 @@ export const apiClient = { try { const patchRes = await guestApi.patch<{ post: RawPost }>( `/posts/${newPostId}`, - patchFormData + patchFormData, + config ); return mapRawPostToPost(patchRes.data.post); } catch { @@ -404,8 +410,9 @@ export const apiClient = { await guestApi.delete(`/posts/${postId}`); }, - likePost: async (postId: string) => { - await guestApi.post(`/posts/${postId}/like`); + likePost: async (postId: string, token?: string) => { + const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined; + await guestApi.post(`/posts/${postId}/like`, {}, config); }, unlikePost: async (postId: string) => { @@ -432,20 +439,25 @@ export const apiClient = { return mapCustomer(res.data.customer); }, - createCustomer: async (data: { - email: string; - userName: string; - firstName: string; - lastName: string; - bio?: string; - }) => { - const res = await guestApi.post<{ customerId: string }>("customers/", data); + createCustomer: async ( + data: { + email: string; + userName: string; + firstName: string; + lastName: string; + bio?: string; + }, + token?: string + ) => { + const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined; + const res = await guestApi.post<{ customerId: string }>("customers/", data, config); markGuestHasCustomer(); return res.data; }, - getCurrentCustomer: async () => { - const res = await guestApi.get<{ customer: RawCustomerResponse }>("customers/"); + getCurrentCustomer: async (token?: string) => { + const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined; + const res = await guestApi.get<{ customer: RawCustomerResponse }>("customers/", config); markGuestHasCustomer(); return mapCustomer(res.data.customer); }, @@ -503,10 +515,12 @@ export const apiClient = { return res.data; }, - createComment: async (postId: string | number, text: string) => { + createComment: async (postId: string | number, text: string, token?: string) => { + const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined; const res = await guestApi.post<{ comment: CommentResponse }>( `/posts/${postId}/comments/`, - { text } + { text }, + config ); return res.data.comment; }, @@ -656,4 +670,53 @@ export const apiClient = { ) => { await guestApi.post(`/collections/${collectionId}/invitations`, { email }); }, + + followUser: async (customerId: string | number, token: string) => { + await guestApi.post(`/customers/id/${customerId}/follow`, {}, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, + + unfollowUser: async (customerId: string | number, token: string) => { + await guestApi.delete(`/customers/id/${customerId}/follow`, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, + + getPostInvitations: async (token: string) => { + const res = await guestApi.get<{ invitations: any[] }>("/posts/invitations", { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.data; + }, + + acceptPostInvitation: async (id: number | string, token: string) => { + await guestApi.post(`/posts/invitations/${id}/accept`, {}, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, + + labRegister: async (data: RegisterRequest) => { + const res = await authApi.post("/register", data); + return res.data; + }, + + labLogin: async (data: LoginRequest) => { + const res = await authApi.post("/login", data); + return res.data; + }, + + getNotificationHistory: async (limit: number, offset: number, token: string) => { + const res = await apiRoot.get(`/notifications/history`, { + params: { limit, offset }, + headers: { Authorization: `Bearer ${token}` }, + }); + return res.data; + }, + + markNotificationsRead: async (notificationIDs: string[], token: string) => { + await apiRoot.post(`/notifications/mark-read`, { notificationIDs }, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, }; diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index c814fbc1..db4e002c 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -68,6 +68,35 @@ export async function markNotificationsRead( } } +export async function fetchNotificationPreferences( + token: string +): Promise> { + const response = await fetch(`${API_BASE_URL}/preferences`, { + headers: authHeaders(token), + }); + + if (!response.ok) { + throw new Error(`Failed to load notification preferences: ${response.status}`); + } + + return response.json(); +} + +export async function updateNotificationPreferences( + token: string, + preferences: Record +): Promise { + const response = await fetch(`${API_BASE_URL}/preferences`, { + method: "PUT", + headers: authHeaders(token), + body: JSON.stringify(preferences), + }); + + if (!response.ok) { + throw new Error(`Failed to update notification preferences: ${response.status}`); + } +} + export function buildNotificationsStreamUrl(token: string) { return `${API_BASE_URL}/stream?access_token=${encodeURIComponent(token)}`; } diff --git a/frontend/src/components/Notifications/NotificationBell.tsx b/frontend/src/components/Notifications/NotificationBell.tsx index 9e6e0f36..00b677c2 100644 --- a/frontend/src/components/Notifications/NotificationBell.tsx +++ b/frontend/src/components/Notifications/NotificationBell.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, CheckCheck } from "lucide-react"; @@ -12,6 +13,7 @@ import { } from "@/components/ui/popover"; import { pageLinkAccent } from "@/components/layout/pageStyles"; import { cn } from "@/lib/utils"; +import { playNotificationSound } from "@/utils/audio"; type NotificationBellProps = { variant?: "default" | "compact"; @@ -30,6 +32,39 @@ export function NotificationBell({ variant = "default" }: NotificationBellProps) enabled: !!token, }); + const prevNewestIdRef = useRef(null); + const isInitialMountRef = useRef(true); + + useEffect(() => { + if (!token || notifications.length === 0) { + prevNewestIdRef.current = null; + isInitialMountRef.current = true; + return; + } + + const newestNotification = notifications[0]; + const newestId = newestNotification.id; + + if (isInitialMountRef.current) { + prevNewestIdRef.current = newestId; + isInitialMountRef.current = false; + return; + } + + if (newestId !== prevNewestIdRef.current) { + prevNewestIdRef.current = newestId; + + if (!newestNotification.read) { + const soundEnabled = localStorage.getItem("notification_sound_enabled") !== "false"; + if (soundEnabled) { + const volStr = localStorage.getItem("notification_sound_volume"); + const volume = volStr ? parseFloat(volStr) / 100 : 0.5; + playNotificationSound(volume); + } + } + } + }, [notifications, token]); + const markRead = useMutation({ mutationFn: (ids: string[]) => markNotificationsRead(token!, ids), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notifications"] }), diff --git a/frontend/src/hooks/useRealtimeNotifications.tsx b/frontend/src/hooks/useRealtimeNotifications.tsx new file mode 100644 index 00000000..ed6f30aa --- /dev/null +++ b/frontend/src/hooks/useRealtimeNotifications.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface LabNotification { + id: string; + type: string; + entityID: string; + metadata?: any; + isRead: boolean; + createdAt: string; + readAt?: string; +} + +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; + +export function useRealtimeNotifications(token: string | null) { + const [notifications, setNotifications] = useState([]); + const [status, setStatus] = useState('disconnected'); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + useEffect(() => { + if (!token) { + setStatus('disconnected'); + setNotifications([]); + return; + } + + const connect = () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + setStatus('connecting'); + const url = `/api/notifications/stream?access_token=${encodeURIComponent(token)}`; + const es = new EventSource(url); + + es.onopen = () => { + console.log("[SSE] Connection established"); + setStatus('connected'); + }; + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as LabNotification; + setNotifications((prev) => [data, ...prev].slice(0, 100)); + } catch (err) { + console.error("[SSE] Failed to parse event data", err); + } + }; + + es.onerror = () => { + console.error("[SSE] Connection error, retrying in 5s"); + setStatus('connecting'); + es.close(); + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + reconnectTimeoutRef.current = window.setTimeout(connect, 5000); + }; + + eventSourceRef.current = es; + }; + + connect(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + }; + }, [token]); + + const clearNotifications = () => setNotifications([]); + + return { + notifications, + setNotifications, + status, + clearNotifications + }; +} diff --git a/frontend/src/pages/guest/Settings/AccountSettingsPage.tsx b/frontend/src/pages/guest/Settings/AccountSettingsPage.tsx index e0fec705..5f02c3f9 100644 --- a/frontend/src/pages/guest/Settings/AccountSettingsPage.tsx +++ b/frontend/src/pages/guest/Settings/AccountSettingsPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { Link } from "react-router-dom"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, + Bell, Camera, Loader2, Mail, @@ -10,9 +11,14 @@ import { Smartphone, Tag, User, + Volume2, } from "lucide-react"; import { toast } from "sonner"; import { apiClient } from "@/api/client"; +import { + fetchNotificationPreferences, + updateNotificationPreferences, +} from "@/api/notifications"; import { useCurrentCustomer } from "@/hooks/useCurrentCustomer"; import { PageHeader } from "@/components/layout/PageHeader"; import { PageLayout } from "@/components/layout/PageLayout"; @@ -31,6 +37,7 @@ import { isBusinessRole, } from "@/utils/auth"; import { cn } from "@/lib/utils"; +import { playNotificationSound } from "@/utils/audio"; const settingsCardClass = "rounded-3xl border border-gray-200 bg-white shadow-sm dark:border-[#2f5e50] dark:bg-[#163d32]"; @@ -95,6 +102,70 @@ function statusPillClass(status: string) { return "border-[#2f5e50] bg-[#0d241d] text-gray-300"; } +function Switch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (val: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +const PREFERENCE_DETAILS = [ + { + key: "post_liked", + label: "Post Likes", + description: "Get notified when someone likes one of your posts", + }, + { + key: "invitation_received", + label: "Collaboration Invitations", + description: "Get notified when you are invited to co-author a post or collection", + }, + { + key: "post_published", + label: "Collaborator Posts Published", + description: "Get notified when a collaborative post you contributed to is published", + }, + { + key: "post_invitation_accepted", + label: "Invitations Accepted", + description: "Get notified when someone accepts your collaborator invitation", + }, + { + key: "business_verified", + label: "Brand Profile Verified", + description: "Get notified when your brand page is verified by our admins", + }, + { + key: "business_rejected", + label: "Brand Profile Rejected", + description: "Get notified if your brand page verification request is rejected", + }, +]; + export function AccountSettingsPage() { const queryClient = useQueryClient(); const payload = getTokenPayload(); @@ -112,6 +183,57 @@ export function AccountSettingsPage() { const [avatarPreview, setAvatarPreview] = useState(null); const fileInputRef = useRef(null); + const [soundEnabled, setSoundEnabled] = useState(() => { + return localStorage.getItem("notification_sound_enabled") !== "false"; + }); + const [soundVolume, setSoundVolume] = useState(() => { + const vol = localStorage.getItem("notification_sound_volume"); + return vol ? parseInt(vol, 10) : 50; + }); + + const handleToggleSound = (enabled: boolean) => { + setSoundEnabled(enabled); + localStorage.setItem("notification_sound_enabled", String(enabled)); + toast.success(enabled ? "Sound notifications enabled" : "Sound notifications muted"); + }; + + const handleVolumeChange = (e: React.ChangeEvent) => { + const vol = parseInt(e.target.value, 10); + setSoundVolume(vol); + localStorage.setItem("notification_sound_volume", String(vol)); + }; + + const handleTestSound = () => { + playNotificationSound(soundVolume / 100); + }; + + const token = localStorage.getItem("token") || ""; + + const { data: preferences, isLoading: prefsLoading } = useQuery({ + queryKey: ["notificationPreferences"], + queryFn: () => fetchNotificationPreferences(token), + enabled: !!token, + }); + + const updatePrefsMutation = useMutation({ + mutationFn: (newPrefs: Record) => + updateNotificationPreferences(token, newPrefs), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["notificationPreferences"] }); + toast.success("Notification preferences updated"); + }, + onError: (error: unknown) => { + const e = error as { response?: { data?: { error?: string } } }; + toast.error(e?.response?.data?.error || "Failed to update preferences"); + }, + }); + + const handleTogglePreference = (key: string, currentValue: boolean) => { + if (!preferences) return; + const updated = { ...preferences, [key]: !currentValue }; + updatePrefsMutation.mutate(updated); + }; + useEffect(() => { if (customer) { setForm({ @@ -476,6 +598,114 @@ export function AccountSettingsPage() { + + +
+ + Notification Preferences +
+ + {prefsLoading ? ( +
+ +
+ ) : preferences ? ( +
+ {PREFERENCE_DETAILS.filter((item) => { + if (item.key.startsWith("business_")) { + return isBusinessRole(); + } + return true; + }).map((pref) => { + const isChecked = !!preferences[pref.key]; + return ( +
+
+ +

+ {pref.description} +

+
+ handleTogglePreference(pref.key, isChecked)} + /> +
+ ); + })} +
+ ) : ( +

+ Unable to load notification preferences. +

+ )} +
+
+ + + +
+
+ + Notification Sounds +
+ +
+ +
+
+
+ +

+ Play a premium chime sound when you receive a new notification +

+
+ +
+ +
+
+ + + {soundVolume}% + +
+ +
+
+
+
+
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f57dfe8b..e1be384a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -49,6 +49,7 @@ export interface CreatePostInput { text: string; rating: number; images?: File[]; + invitedCustomerIds?: string[]; } export interface CustomerResponse { diff --git a/frontend/src/utils/audio.ts b/frontend/src/utils/audio.ts new file mode 100644 index 00000000..6dd67f58 --- /dev/null +++ b/frontend/src/utils/audio.ts @@ -0,0 +1,39 @@ +export const playNotificationSound = (volume: number) => { + try { + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; + if (!AudioContextClass) return; + const ctx = new AudioContextClass(); + + const osc1 = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + osc1.type = "sine"; + osc2.type = "sine"; + + osc1.frequency.setValueAtTime(587.33, ctx.currentTime); + osc2.frequency.setValueAtTime(880.00, ctx.currentTime); + + gainNode.gain.setValueAtTime(0, ctx.currentTime); + gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 0.05); + gainNode.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.5); + + osc1.connect(gainNode); + osc2.connect(gainNode); + gainNode.connect(ctx.destination); + + osc1.start(ctx.currentTime); + osc2.start(ctx.currentTime); + + osc1.stop(ctx.currentTime + 0.6); + osc2.stop(ctx.currentTime + 0.6); + + setTimeout(() => { + ctx.close().catch((err: any) => { + console.error("Failed to close AudioContext", err); + }); + }, 700); + } catch (e) { + console.error("Failed to play synthesized notification sound", e); + } +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 5c05de1a..9fe2d9cb 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -28,5 +28,5 @@ "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src/**/*"] } \ No newline at end of file diff --git a/internal/business/handler/business/handler.go b/internal/business/handler/business/handler.go index bb403b9e..879d5ac6 100644 --- a/internal/business/handler/business/handler.go +++ b/internal/business/handler/business/handler.go @@ -65,7 +65,7 @@ type businessService interface { ReserveBox(ctx context.Context, userID string, boxID int64) (*entity.BoxReservation, error) ListBoxesByBusiness(ctx context.Context, userID string, offset, limit int) (pagination.Result[entity.Box], error) UpdateBox(ctx context.Context, boxID int64, userID string, input entity.BoxUpdateInput) (*entity.Box, error) - GetBox(ctx context.Context, boxID int64) (*entity.Box, error) + GetBox(ctx context.Context, boxID int64) (*entity.Box, error) GetBoxReservations(ctx context.Context, boxID int64, userID string, offset, limit int) (pagination.Result[entity.BoxItem], error) Rating(ctx context.Context, id int) (float32, error) @@ -213,7 +213,7 @@ func (h *handler) ready(c *gin.Context) { _, err := h.service.ListLocationTags(ctx) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "service not ready", + "error": "service not ready", }) return } diff --git a/internal/notification/handler/handler.go b/internal/notification/handler/handler.go index 7f6ff3eb..0a897347 100644 --- a/internal/notification/handler/handler.go +++ b/internal/notification/handler/handler.go @@ -38,6 +38,8 @@ func RegisterHandlers(r *gin.RouterGroup, notificationService *service.Service, auth.GET("/history", h.getHistory) auth.POST("/mark-read", h.markAsRead) auth.GET("/stream", h.stream) + auth.GET("/preferences", h.getPreferences) + auth.PUT("/preferences", h.updatePreferences) } } @@ -193,3 +195,42 @@ func (h *handler) stream(c *gin.Context) { } }) } + +func (h *handler) getPreferences(c *gin.Context) { + userID, err := httpctx.GetUserID(c) + if err != nil { + c.Error(err) + return + } + + prefs, err := h.svc.GetPreferences(c.Request.Context(), userID) + if err != nil { + logger.ErrorKV(c.Request.Context(), "get notification preferences", "error", err) + c.Error(err) + return + } + + c.JSON(http.StatusOK, prefs) +} + +func (h *handler) updatePreferences(c *gin.Context) { + userID, err := httpctx.GetUserID(c) + if err != nil { + c.Error(err) + return + } + + var req map[string]bool + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(apperror.BadRequest(err.Error())) + return + } + + if err := h.svc.UpdatePreferences(c.Request.Context(), userID, req); err != nil { + logger.ErrorKV(c.Request.Context(), "update notification preferences", "error", err) + c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/notification/repository/repository.go b/internal/notification/repository/repository.go index c4ccd648..d507bbbf 100644 --- a/internal/notification/repository/repository.go +++ b/internal/notification/repository/repository.go @@ -3,9 +3,11 @@ package repository import ( "context" "encoding/json" + "errors" "fmt" "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" "github.com/ua-academy-projects/share-bite/internal/notification/entity" "github.com/ua-academy-projects/share-bite/pkg/database" ) @@ -14,6 +16,8 @@ type NotificationRepository interface { Save(ctx context.Context, notification entity.Notification) (bool, error) GetHistory(ctx context.Context, recipientID string, limit, offset int) ([]entity.Notification, error) MarkAsRead(ctx context.Context, recipientID string, notificationIDs []string) error + GetPreferences(ctx context.Context, recipientID string) (map[string]bool, error) + UpdatePreferences(ctx context.Context, recipientID string, prefs map[string]bool) error } type SQLRepository struct { @@ -100,4 +104,63 @@ func (r *SQLRepository) MarkAsRead(ctx context.Context, recipientID string, noti return nil } +func (r *SQLRepository) GetPreferences(ctx context.Context, recipientID string) (map[string]bool, error) { + q := database.Query{ + Name: "notification_repository.GetPreferences", + Sql: ` + SELECT settings + FROM notification_preferences + WHERE recipient_id = $1 + `, + } + + row, err := r.db.QueryContext(ctx, q, recipientID) + if err != nil { + return nil, fmt.Errorf("query preferences: %w", err) + } + defer row.Close() + + var settingsJSON []byte + if err := pgxscan.ScanOne(&settingsJSON, row); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return make(map[string]bool), nil + } + return nil, fmt.Errorf("scan preferences: %w", err) + } + + var prefs map[string]bool + if err := json.Unmarshal(settingsJSON, &prefs); err != nil { + return nil, fmt.Errorf("unmarshal preferences: %w", err) + } + + return prefs, nil +} + +func (r *SQLRepository) UpdatePreferences(ctx context.Context, recipientID string, prefs map[string]bool) error { + if prefs == nil { + prefs = make(map[string]bool) + } + + prefsJSON, err := json.Marshal(prefs) + if err != nil { + return fmt.Errorf("marshal preferences: %w", err) + } + + q := database.Query{ + Name: "notification_repository.UpdatePreferences", + Sql: ` + INSERT INTO notification_preferences (recipient_id, settings) + VALUES ($1, $2::jsonb) + ON CONFLICT (recipient_id) + DO UPDATE SET settings = notification_preferences.settings || EXCLUDED.settings + `, + } + + if _, err := r.db.ExecContext(ctx, q, recipientID, string(prefsJSON)); err != nil { + return fmt.Errorf("upsert notification preferences: %w", err) + } + + return nil +} + var _ NotificationRepository = (*SQLRepository)(nil) diff --git a/internal/notification/service/service.go b/internal/notification/service/service.go index 5e8219a2..8af8a400 100644 --- a/internal/notification/service/service.go +++ b/internal/notification/service/service.go @@ -41,6 +41,16 @@ func (s *Service) ProcessMessage(ctx context.Context, msg notification.Message) return fmt.Errorf("recipient_id is required") } + prefs, err := s.repo.GetPreferences(ctx, msg.RecipientID) + if err != nil { + return fmt.Errorf("get notification preferences: %w", err) + } + + if enabled, ok := prefs[string(msg.EventType)]; ok && !enabled { + logger.InfoKV(ctx, "notification skipped due to user preference", "notification_id", msg.EventID, "recipient_id", msg.RecipientID, "event_type", msg.EventType) + return nil + } + inserted, err := s.repo.Save(ctx, notificationentity.FromMessage(msg)) if err != nil { return err @@ -111,3 +121,46 @@ func (s *Service) GetHistory(ctx context.Context, recipientID string, limit, off func (s *Service) MarkAsRead(ctx context.Context, recipientID string, notificationIDs []string) error { return s.repo.MarkAsRead(ctx, recipientID, notificationIDs) } + +func (s *Service) GetPreferences(ctx context.Context, recipientID string) (map[string]bool, error) { + dbPrefs, err := s.repo.GetPreferences(ctx, recipientID) + if err != nil { + return nil, err + } + + result := map[string]bool{ + "post_liked": true, + "invitation_received": true, + "post_published": true, + "post_invitation_accepted": true, + "business_verified": true, + "business_rejected": true, + } + + for k, v := range dbPrefs { + if _, ok := result[k]; ok { + result[k] = v + } + } + + return result, nil +} + +func (s *Service) UpdatePreferences(ctx context.Context, recipientID string, prefs map[string]bool) error { + validKeys := map[string]bool{ + "post_liked": true, + "invitation_received": true, + "post_published": true, + "post_invitation_accepted": true, + "business_verified": true, + "business_rejected": true, + } + + for k := range prefs { + if !validKeys[k] { + return fmt.Errorf("unsupported preference key: %s", k) + } + } + + return s.repo.UpdatePreferences(ctx, recipientID, prefs) +} diff --git a/migrations/20260622100000_notification_preferences.sql b/migrations/20260622100000_notification_preferences.sql new file mode 100644 index 00000000..464ef2ca --- /dev/null +++ b/migrations/20260622100000_notification_preferences.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS notification_preferences ( + recipient_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + settings JSONB NOT NULL DEFAULT '{}'::jsonb +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS notification_preferences; +-- +goose StatementEnd