diff --git a/project-portal/project-portal-backend/cmd/workers/alert_worker.go b/project-portal/project-portal-backend/cmd/workers/alert_worker.go index 9f38db9f..4477d615 100644 --- a/project-portal/project-portal-backend/cmd/workers/alert_worker.go +++ b/project-portal/project-portal-backend/cmd/workers/alert_worker.go @@ -1,7 +1,103 @@ -//go:build future -// +build future - package workers -// This file won't be compiled in normal builds -// Implementation pending +import ( + "context" + "errors" + "log" + "sync" + "time" +) + +// AlertEvaluationWorker manages periodic evaluation of monitoring alerts. +type AlertEvaluationWorker struct { + interval time.Duration + logger *log.Logger + mu sync.RWMutex +} + +// NewAlertEvaluationWorker creates a new alert evaluation worker. +func NewAlertEvaluationWorker(interval time.Duration, logger *log.Logger) *AlertEvaluationWorker { + if interval <= 0 { + interval = 1 * time.Minute + } + if logger == nil { + logger = log.Default() + } + return &AlertEvaluationWorker{ + interval: interval, + logger: logger, + } +} + +// Start begins the alert evaluation loop and blocks until context is cancelled. +func (w *AlertEvaluationWorker) Start(ctx context.Context) error { + if ctx == nil { + return errors.New("context cannot be nil") + } + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + w.logger.Printf("alert evaluation worker started with interval: %v\n", w.interval) + + for { + select { + case <-ctx.Done(): + w.logger.Println("alert evaluation worker: context cancelled, initiating graceful shutdown") + return ctx.Err() + case <-ticker.C: + w.evaluateAlerts(ctx) + } + } +} + +// evaluateAlerts runs the alert evaluation cycle. +func (w *AlertEvaluationWorker) evaluateAlerts(ctx context.Context) { + w.mu.RLock() + defer w.mu.RUnlock() + + w.logger.Println("alert evaluation worker: triggered evaluation cycle") + + activeAlerts := w.getActiveAlertsMock() + if len(activeAlerts) == 0 { + w.logger.Println("alert evaluation worker: no active alerts to evaluate") + return + } + + w.logger.Printf("alert evaluation worker: evaluating %d alerts\n", len(activeAlerts)) + + for _, alertID := range activeAlerts { + if err := w.evaluateSingleAlert(ctx, alertID); err != nil { + w.logger.Printf("alert evaluation worker: error evaluating alert %s: %v\n", alertID, err) + } else { + w.logger.Printf("alert evaluation worker: successfully evaluated alert %s\n", alertID) + } + } + + w.logger.Println("alert evaluation worker: evaluation cycle completed") +} + +// evaluateSingleAlert evaluates a single alert. +func (w *AlertEvaluationWorker) evaluateSingleAlert(ctx context.Context, alertID string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Mock: Retrieve alert configuration + // Mock: Fetch latest monitoring data + // Mock: Evaluate alert conditions + // Mock: Trigger notifications if threshold breached + + return nil +} + +// getActiveAlertsMock returns a list of mock active alert IDs. +func (w *AlertEvaluationWorker) getActiveAlertsMock() []string { + return []string{ + "alert-001", + "alert-002", + "alert-003", + } +} diff --git a/project-portal/project-portal-backend/internal/notifications/channels/websocket.go b/project-portal/project-portal-backend/internal/notifications/channels/websocket.go index 4ef17ced..46c1f4fa 100644 --- a/project-portal/project-portal-backend/internal/notifications/channels/websocket.go +++ b/project-portal/project-portal-backend/internal/notifications/channels/websocket.go @@ -1,7 +1,147 @@ -//go:build future -// +build future +package channels -package notifications +import ( + "context" + "errors" + "fmt" + "time" -// This file won't be compiled in normal builds -// Implementation pending + "carbon-scribe/project-portal/project-portal-backend/pkg/aws" +) + +// WebSocketConnection mirrors the type from notifications package to avoid import cycle +type WebSocketConnection struct { + ConnectionID string `json:"connection_id" bson:"_id"` + UserID string `json:"user_id" bson:"user_id"` + ProjectIDs []string `json:"project_ids" bson:"project_ids"` + ConnectedAt time.Time `json:"connected_at" bson:"connected_at"` + LastActivity time.Time `json:"last_activity" bson:"last_activity"` + UserAgent string `json:"user_agent,omitempty" bson:"user_agent,omitempty"` + IPAddress string `json:"ip_address,omitempty" bson:"ip_address,omitempty"` +} + +// WebSocketNotification mirrors the type from notifications package to avoid import cycle +type WebSocketNotification struct { + ID string `json:"id" bson:"_id"` + UserID string `json:"user_id" bson:"user_id"` + ProjectID string `json:"project_id,omitempty" bson:"project_id,omitempty"` + Category string `json:"category" bson:"category"` + Subject string `json:"subject" bson:"subject"` + Content string `json:"content" bson:"content"` + Channels []string `json:"channels" bson:"channels"` + Status string `json:"status" bson:"status"` + TemplateID string `json:"template_id,omitempty" bson:"template_id,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty" bson:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` + DeliveredAt *time.Time `json:"delivered_at,omitempty" bson:"delivered_at,omitempty"` +} + +// WebSocketRepo defines the minimal interface needed from the repository to avoid import cycle +type WebSocketRepo interface { + ListConnections(ctx context.Context, projectID string, userID string) ([]WebSocketConnection, error) +} + +// WebSocketSender defines the interface for sending WebSocket notifications. +type WebSocketSender interface { + Send(ctx context.Context, userID string, notification *WebSocketNotification) error + Broadcast(ctx context.Context, projectID string, notification *WebSocketNotification) (int, error) +} + +// WebSocketChannel implements WebSocket notification delivery. +type WebSocketChannel struct { + repo WebSocketRepo + apiClient *aws.APIGatewayClient + retryLimit int +} + +// NewWebSocketChannel creates a new WebSocket notification channel. +func NewWebSocketChannel(repo WebSocketRepo, apiClient *aws.APIGatewayClient) *WebSocketChannel { + return &WebSocketChannel{ + repo: repo, + apiClient: apiClient, + retryLimit: 3, + } +} + +// Send sends a notification to all active WebSocket connections for a user. +func (w *WebSocketChannel) Send(ctx context.Context, userID string, notification *WebSocketNotification) error { + if userID == "" { + return errors.New("user ID is required") + } + if notification == nil { + return errors.New("notification is required") + } + + conns, err := w.repo.ListConnections(ctx, "", userID) + if err != nil { + return fmt.Errorf("failed to list connections: %w", err) + } + + if len(conns) == 0 { + return fmt.Errorf("no active connections for user %s", userID) + } + + var lastErr error + sentCount := 0 + + for _, conn := range conns { + for attempt := 0; attempt <= w.retryLimit; attempt++ { + err := w.sendToConnection(ctx, conn.ConnectionID, notification) + if err == nil { + sentCount++ + break + } + lastErr = err + if attempt < w.retryLimit { + time.Sleep(time.Duration((attempt + 1) * 100) * time.Millisecond) + } + } + } + + if sentCount == 0 && lastErr != nil { + return lastErr + } + + return nil +} + +// Broadcast sends a notification to all connections in a project. +func (w *WebSocketChannel) Broadcast(ctx context.Context, projectID string, notification *WebSocketNotification) (int, error) { + if projectID == "" { + return 0, errors.New("project ID is required") + } + if notification == nil { + return 0, errors.New("notification is required") + } + + conns, err := w.repo.ListConnections(ctx, projectID, "") + if err != nil { + return 0, fmt.Errorf("failed to list connections: %w", err) + } + + sentCount := 0 + for _, conn := range conns { + for attempt := 0; attempt <= w.retryLimit; attempt++ { + err := w.sendToConnection(ctx, conn.ConnectionID, notification) + if err == nil { + sentCount++ + break + } + if attempt < w.retryLimit { + time.Sleep(time.Duration((attempt + 1) * 100) * time.Millisecond) + } + } + } + + return sentCount, nil +} + +// sendToConnection sends a notification to a single WebSocket connection. +func (w *WebSocketChannel) sendToConnection(ctx context.Context, connectionID string, notification *WebSocketNotification) error { + if w.apiClient != nil { + return w.apiClient.PostToConnection(ctx, connectionID, notification) + } + // If no API client (local dev), just return success (mock delivery) + return nil +} diff --git a/project-portal/project-portal-backend/pkg/aws/apigateway.go b/project-portal/project-portal-backend/pkg/aws/apigateway.go index 99134ea4..8b3f923a 100644 --- a/project-portal/project-portal-backend/pkg/aws/apigateway.go +++ b/project-portal/project-portal-backend/pkg/aws/apigateway.go @@ -1,7 +1,117 @@ -//go:build future -// +build future - package aws -// This file won't be compiled in normal builds -// Implementation pending +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" +) + +// APIGatewayConfig holds configuration for API Gateway client. +type APIGatewayConfig struct { + Region string + AccessKeyID string + SecretAccessKey string + Endpoint string + Stage string +} + +// APIGatewayClient manages interactions with AWS API Gateway's WebSocket API. +type APIGatewayClient struct { + httpClient *http.Client + endpoint string +} + +// NewAPIGatewayClient creates a new API Gateway WebSocket client. +func NewAPIGatewayClient(cfg APIGatewayConfig) (*APIGatewayClient, error) { + opts := []func(*config.LoadOptions) error{ + config.WithRegion(cfg.Region), + } + + if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + )) + } + + _, err := config.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + endpoint := cfg.Endpoint + if cfg.Stage != "" && endpoint != "" { + endpoint = fmt.Sprintf("%s/%s", endpoint, cfg.Stage) + } + + return &APIGatewayClient{ + httpClient: &http.Client{Timeout: 10}, + endpoint: endpoint, + }, nil +} + +// PostToConnection sends a message to a specific WebSocket connection. +func (c *APIGatewayClient) PostToConnection(ctx context.Context, connectionID string, data interface{}) error { + if c.endpoint == "" { + return fmt.Errorf("API Gateway endpoint not configured") + } + if connectionID == "" { + return fmt.Errorf("connection ID is required") + } + + payload, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + url := fmt.Sprintf("%s/@connections/%s", c.endpoint, connectionID) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("API Gateway returned status %d", resp.StatusCode) + } + + return nil +} + +// DeleteConnection closes a specific WebSocket connection. +func (c *APIGatewayClient) DeleteConnection(ctx context.Context, connectionID string) error { + if c.endpoint == "" { + return fmt.Errorf("API Gateway endpoint not configured") + } + if connectionID == "" { + return fmt.Errorf("connection ID is required") + } + + url := fmt.Sprintf("%s/@connections/%s", c.endpoint, connectionID) + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to delete connection: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("API Gateway returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/project-portal/project-portal-web/src/lib/store/geospatial/geospatial.api.ts b/project-portal/project-portal-web/src/lib/store/geospatial/geospatial.api.ts new file mode 100644 index 00000000..3f8fd490 --- /dev/null +++ b/project-portal/project-portal-web/src/lib/store/geospatial/geospatial.api.ts @@ -0,0 +1,42 @@ +import apiClient from '@/lib/api/apiClient'; +import type { ProjectGeometry, Geofence, MapTile, Geometry } from './geospatial.types'; + +export async function fetchProjectGeometryApi(projectId: string): Promise { + const response = await apiClient.get(`/geospatial/projects/${projectId}/geometry`); + return response.data; +} + +export async function fetchAllProjectGeometriesApi(): Promise { + const response = await apiClient.get<{ geometries: ProjectGeometry[] }>('/geospatial/geometries'); + return response.data.geometries || []; +} + +export async function updateProjectGeometryApi(projectId: string, geometry: Geometry): Promise { + const response = await apiClient.put(`/geospatial/projects/${projectId}/geometry`, { geometry }); + return response.data; +} + +export async function fetchGeofencesApi(projectId: string): Promise { + const response = await apiClient.get<{ geofences: Geofence[] }>(`/geospatial/projects/${projectId}/geofences`); + return response.data.geofences || []; +} + +export async function createGeofenceApi(projectId: string, data: Omit): Promise { + const response = await apiClient.post(`/geospatial/projects/${projectId}/geofences`, data); + return response.data; +} + +export async function updateGeofenceApi(id: string, data: Partial>): Promise { + const response = await apiClient.put(`/geospatial/geofences/${id}`, data); + return response.data; +} + +export async function deleteGeofenceApi(id: string): Promise { + await apiClient.delete(`/geospatial/geofences/${id}`); +} + +export async function fetchMapTilesApi(projectId: string, type?: string): Promise { + const params = type ? { type } : {}; + const response = await apiClient.get<{ tiles: MapTile[] }>(`/geospatial/projects/${projectId}/tiles`, { params }); + return response.data.tiles || []; +} diff --git a/project-portal/project-portal-web/src/lib/store/geospatial/geospatial.types.ts b/project-portal/project-portal-web/src/lib/store/geospatial/geospatial.types.ts new file mode 100644 index 00000000..b2105d12 --- /dev/null +++ b/project-portal/project-portal-web/src/lib/store/geospatial/geospatial.types.ts @@ -0,0 +1,72 @@ +// TypeScript interfaces for the Geospatial domain + +export interface Geometry { + type: string; + coordinates: number[] | number[][] | number[][][]; +} + +export interface ProjectGeometry { + id: string; + projectId: string; + geometry: Geometry; + createdAt: string; + updatedAt: string; +} + +export interface Geofence { + id: string; + projectId: string; + name: string; + geometry: Geometry; + type: 'active' | 'historical' | 'breached'; + createdAt: string; + updatedAt: string; +} + +export interface MapTile { + id: string; + projectId: string; + type: 'raster' | 'ndvi' | 'satellite'; + url: string; + bounds: [number, number, number, number]; + createdAt: string; +} + +export interface GeospatialLoadingState { + isFetchingGeometry: boolean; + isFetchingGeofences: boolean; + isFetchingTiles: boolean; + isUpdating: boolean; +} + +export interface GeospatialErrorState { + fetchGeometry: string | null; + fetchGeofences: string | null; + fetchTiles: string | null; + update: string | null; +} + +export interface GeospatialSlice { + // State + projectGeometries: ProjectGeometry[]; + geofences: Geofence[]; + mapTiles: MapTile[]; + selectedGeometry: ProjectGeometry | null; + selectedGeofence: Geofence | null; + geospatialLoading: GeospatialLoadingState; + geospatialErrors: GeospatialErrorState; + + // Actions + fetchProjectGeometry: (projectId: string) => Promise; + fetchAllProjectGeometries: () => Promise; + updateProjectGeometry: (projectId: string, geometry: Geometry) => Promise; + fetchGeofences: (projectId: string) => Promise; + createGeofence: (projectId: string, data: Omit) => Promise; + updateGeofence: (id: string, data: Partial>) => Promise; + deleteGeofence: (id: string) => Promise; + fetchMapTiles: (projectId: string, type?: string) => Promise; + setSelectedGeometry: (geometry: ProjectGeometry | null) => void; + setSelectedGeofence: (geofence: Geofence | null) => void; + clearGeospatialErrors: () => void; + resetGeospatialState: () => void; +} diff --git a/project-portal/project-portal-web/src/lib/store/geospatial/geospatialSlice.ts b/project-portal/project-portal-web/src/lib/store/geospatial/geospatialSlice.ts new file mode 100644 index 00000000..f6530213 --- /dev/null +++ b/project-portal/project-portal-web/src/lib/store/geospatial/geospatialSlice.ts @@ -0,0 +1,242 @@ +import { StateCreator } from 'zustand'; +import type { GeospatialSlice, ProjectGeometry, Geofence, MapTile, Geometry } from './geospatial.types'; +import { + fetchProjectGeometryApi, + fetchAllProjectGeometriesApi, + updateProjectGeometryApi, + fetchGeofencesApi, + createGeofenceApi, + updateGeofenceApi, + deleteGeofenceApi, + fetchMapTilesApi, +} from './geospatial.api'; +import { getErrorMessage } from '@/lib/utils/errorMessage'; +import { showSuccessToast, showErrorToast } from '@/lib/utils/toast'; + +const initialState: Pick< + GeospatialSlice, + 'projectGeometries' | 'geofences' | 'mapTiles' | 'selectedGeometry' | 'selectedGeofence' | 'geospatialLoading' | 'geospatialErrors' +> = { + projectGeometries: [], + geofences: [], + mapTiles: [], + selectedGeometry: null, + selectedGeofence: null, + geospatialLoading: { + isFetchingGeometry: false, + isFetchingGeofences: false, + isFetchingTiles: false, + isUpdating: false, + }, + geospatialErrors: { + fetchGeometry: null, + fetchGeofences: null, + fetchTiles: null, + update: null, + }, +}; + +export const createGeospatialSlice: StateCreator = (set, get) => ({ + ...initialState, + + fetchProjectGeometry: async (projectId: string) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingGeometry: true }, + geospatialErrors: { ...state.geospatialErrors, fetchGeometry: null }, + })); + + try { + const geometry = await fetchProjectGeometryApi(projectId); + set((state) => ({ + projectGeometries: state.projectGeometries + .filter((g) => g.projectId !== projectId) + .concat([geometry]), + geospatialLoading: { ...get().geospatialLoading, isFetchingGeometry: false }, + })); + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingGeometry: false }, + geospatialErrors: { ...state.geospatialErrors, fetchGeometry: getErrorMessage(error) }, + })); + } + }, + + fetchAllProjectGeometries: async () => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingGeometry: true }, + geospatialErrors: { ...state.geospatialErrors, fetchGeometry: null }, + })); + + try { + const geometries = await fetchAllProjectGeometriesApi(); + set({ + projectGeometries: geometries, + geospatialLoading: { ...get().geospatialLoading, isFetchingGeometry: false }, + }); + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingGeometry: false }, + geospatialErrors: { ...state.geospatialErrors, fetchGeometry: getErrorMessage(error) }, + })); + } + }, + + updateProjectGeometry: async (projectId: string, geometry: Geometry) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: true }, + geospatialErrors: { ...state.geospatialErrors, update: null }, + })); + + try { + const updatedGeometry = await updateProjectGeometryApi(projectId, geometry); + set((state) => ({ + projectGeometries: state.projectGeometries + .map((g) => (g.projectId === projectId ? updatedGeometry : g)), + selectedGeometry: state.selectedGeometry?.projectId === projectId ? updatedGeometry : state.selectedGeometry, + geospatialLoading: { ...get().geospatialLoading, isUpdating: false }, + })); + showSuccessToast('Geometry updated successfully'); + return updatedGeometry; + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: false }, + geospatialErrors: { ...state.geospatialErrors, update: getErrorMessage(error) }, + })); + showErrorToast('Failed to update geometry'); + return null; + } + }, + + fetchGeofences: async (projectId: string) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingGeofences: true }, + geospatialErrors: { ...state.geospatialErrors, fetchGeofences: null }, + })); + + try { + const geofences = await fetchGeofencesApi(projectId); + set((state) => ({ + geofences: state.geofences + .filter((g) => g.projectId !== projectId) + .concat(geofences), + geospatialLoading: { ...get().geospatialLoading, isFetchingGeofences: false }, + })); + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingGeofences: false }, + geospatialErrors: { ...state.geospatialErrors, fetchGeofences: getErrorMessage(error) }, + })); + } + }, + + createGeofence: async (projectId: string, data: Omit) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: true }, + geospatialErrors: { ...state.geospatialErrors, update: null }, + })); + + try { + const newGeofence = await createGeofenceApi(projectId, data); + set((state) => ({ + geofences: [...state.geofences, newGeofence], + geospatialLoading: { ...get().geospatialLoading, isUpdating: false }, + })); + showSuccessToast('Geofence created successfully'); + return newGeofence; + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: false }, + geospatialErrors: { ...state.geospatialErrors, update: getErrorMessage(error) }, + })); + showErrorToast('Failed to create geofence'); + return null; + } + }, + + updateGeofence: async (id: string, data: Partial>) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: true }, + geospatialErrors: { ...state.geospatialErrors, update: null }, + })); + + try { + const updatedGeofence = await updateGeofenceApi(id, data); + set((state) => ({ + geofences: state.geofences.map((g) => (g.id === id ? updatedGeofence : g)), + selectedGeofence: state.selectedGeofence?.id === id ? updatedGeofence : state.selectedGeofence, + geospatialLoading: { ...get().geospatialLoading, isUpdating: false }, + })); + showSuccessToast('Geofence updated successfully'); + return updatedGeofence; + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: false }, + geospatialErrors: { ...state.geospatialErrors, update: getErrorMessage(error) }, + })); + showErrorToast('Failed to update geofence'); + return null; + } + }, + + deleteGeofence: async (id: string) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: true }, + geospatialErrors: { ...state.geospatialErrors, update: null }, + })); + + try { + await deleteGeofenceApi(id); + set((state) => ({ + geofences: state.geofences.filter((g) => g.id !== id), + selectedGeofence: state.selectedGeofence?.id === id ? null : state.selectedGeofence, + geospatialLoading: { ...get().geospatialLoading, isUpdating: false }, + })); + showSuccessToast('Geofence deleted successfully'); + return true; + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isUpdating: false }, + geospatialErrors: { ...state.geospatialErrors, update: getErrorMessage(error) }, + })); + showErrorToast('Failed to delete geofence'); + return false; + } + }, + + fetchMapTiles: async (projectId: string, type?: string) => { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingTiles: true }, + geospatialErrors: { ...state.geospatialErrors, fetchTiles: null }, + })); + + try { + const tiles = await fetchMapTilesApi(projectId, type); + set((state) => ({ + mapTiles: state.mapTiles + .filter((t) => t.projectId !== projectId || (type && t.type !== type)) + .concat(tiles), + geospatialLoading: { ...get().geospatialLoading, isFetchingTiles: false }, + })); + } catch (error: unknown) { + set((state) => ({ + geospatialLoading: { ...state.geospatialLoading, isFetchingTiles: false }, + geospatialErrors: { ...state.geospatialErrors, fetchTiles: getErrorMessage(error) }, + })); + } + }, + + setSelectedGeometry: (geometry) => set({ selectedGeometry: geometry }), + setSelectedGeofence: (geofence) => set({ selectedGeofence: geofence }), + + clearGeospatialErrors: () => + set({ + geospatialErrors: { + fetchGeometry: null, + fetchGeofences: null, + fetchTiles: null, + update: null, + }, + }), + + resetGeospatialState: () => set({ ...initialState }), +}); diff --git a/project-portal/project-portal-web/src/lib/store/store.ts b/project-portal/project-portal-web/src/lib/store/store.ts index 130c1996..e9c0863c 100644 --- a/project-portal/project-portal-web/src/lib/store/store.ts +++ b/project-portal/project-portal-web/src/lib/store/store.ts @@ -18,6 +18,8 @@ import type { NotificationsSlice } from "@/store/notification.types"; import { createNotificationsSlice } from "@/store/notificationsSlice"; import type { FinancingSlice } from "./financing/financing.types"; import { createFinancingSlice } from "./financing/financingSlice"; +import type { GeospatialSlice } from "./geospatial/geospatial.types"; +import { createGeospatialSlice } from "./geospatial/geospatialSlice"; // Unified store state type export type StoreState = AuthSlice & @@ -26,7 +28,8 @@ export type StoreState = AuthSlice & SearchSlice & HealthSlice & NotificationsSlice & - FinancingSlice; + FinancingSlice & + GeospatialSlice; // Helper to check if token is expired or about to expire (60s buffer) const isTokenExpiringSoon = (expiresIn: number | null): boolean => { @@ -45,6 +48,7 @@ export const useStore = create()( ...createHealthSlice(...args), ...createNotificationsSlice(...args), ...createFinancingSlice(...args), + ...createGeospatialSlice(...args), }), { name: "project-portal-store", @@ -74,7 +78,7 @@ export const useStore = create()( })); } - + const path = window.location.pathname; const isAuthPage = path === "/login" || path === "/register"; const isAuthenticated = state?.isAuthenticated === true;