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