public shares now work

This commit is contained in:
2025-12-12 22:06:05 +05:00
parent 1f088c8fb0
commit f5370aa7f9
9 changed files with 117 additions and 79 deletions

View File

@@ -6,7 +6,7 @@ server {
error_log /dev/stdout info; error_log /dev/stdout info;
location /api/ { location /api/ {
proxy_pass http://backend:8000/api/; proxy_pass http://python:8000/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -15,15 +15,24 @@ server {
} }
location /ws/ { location /ws/ {
proxy_pass http://backend:8000/ws/; proxy_pass http://python:8000/ws/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeout settings
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 60s;
} }
location / { location / {
# In development, proxy to Vite dev server # In development, proxy to Vite dev server
proxy_pass http://frontend:3000; proxy_pass http://node:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";

View File

@@ -1,7 +1,6 @@
from app.models.user import User from app.models.user import User
from app.models.map import Map from app.models.map import Map
from app.models.map_item import MapItem from app.models.map_item import MapItem
from app.models.share import Share from app.models.map_share import MapShare, MapShareLink
from app.models.session import Session
__all__ = ["User", "Map", "MapItem", "Share", "Session"] __all__ = ["User", "Map", "MapItem", "MapShare", "MapShareLink"]

View File

@@ -20,7 +20,7 @@ class MapShare(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True) map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ) permission = Column(SQLEnum(SharePermission, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SharePermission.READ)
shared_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) shared_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@@ -34,7 +34,7 @@ class MapShareLink(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True) map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
token = Column(String(64), unique=True, nullable=False, index=True) # Random token for the share URL token = Column(String(64), unique=True, nullable=False, index=True) # Random token for the share URL
permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ) permission = Column(SQLEnum(SharePermission, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SharePermission.READ)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration

View File

@@ -10,6 +10,7 @@ from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate, MapItemResponse from app.schemas.map_item import MapItemCreate, MapItemUpdate, MapItemResponse
from app.services import item_service from app.services import item_service
from app.services.item_service import geography_to_geojson from app.services.item_service import geography_to_geojson
from app.websocket.connection_manager import manager
router = APIRouter(prefix="/api/maps/{map_id}/items", tags=["map-items"]) router = APIRouter(prefix="/api/maps/{map_id}/items", tags=["map-items"])
@@ -50,7 +51,12 @@ async def create_map_item(
): ):
"""Create a new map item.""" """Create a new map item."""
item = item_service.create_map_item(db, map_id, item_data, current_user) item = item_service.create_map_item(db, map_id, item_data, current_user)
return format_item_response(item) response = format_item_response(item)
# Broadcast to WebSocket clients
await manager.send_item_created(map_id, response)
return response
@router.get("/{item_id}", response_model=dict) @router.get("/{item_id}", response_model=dict)
@@ -75,7 +81,12 @@ async def update_map_item(
): ):
"""Update a map item.""" """Update a map item."""
item = item_service.update_map_item(db, item_id, item_data, current_user) item = item_service.update_map_item(db, item_id, item_data, current_user)
return format_item_response(item) response = format_item_response(item)
# Broadcast to WebSocket clients
await manager.send_item_updated(map_id, response)
return response
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -87,4 +98,8 @@ async def delete_map_item(
): ):
"""Delete a map item.""" """Delete a map item."""
item_service.delete_map_item(db, item_id, current_user) item_service.delete_map_item(db, item_id, current_user)
# Broadcast to WebSocket clients
await manager.send_item_deleted(map_id, str(item_id))
return None return None

View File

@@ -1,11 +1,10 @@
"""WebSocket routes for real-time updates.""" """WebSocket routes for real-time updates."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from sqlalchemy.orm import Session
from uuid import UUID from uuid import UUID
from typing import Optional from typing import Optional
import logging import logging
from app.database import get_db from app.database import SessionLocal
from app.websocket.connection_manager import manager from app.websocket.connection_manager import manager
from app.services.map_share_service import check_map_access from app.services.map_share_service import check_map_access
from app.dependencies import get_user_from_token from app.dependencies import get_user_from_token
@@ -20,11 +19,10 @@ async def websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
map_id: UUID, map_id: UUID,
token: Optional[str] = Query(None), token: Optional[str] = Query(None),
share_token: Optional[str] = Query(None), share_token: Optional[str] = Query(None)
db: Session = Depends(get_db)
): ):
""" """
WebSocket endpoint for real-time map updates. WebSocket endpoint for real-time updates.
Clients can connect using: Clients can connect using:
- JWT token (authenticated users) - JWT token (authenticated users)
@@ -33,23 +31,38 @@ async def websocket_endpoint(
Example: ws://localhost:8000/ws/maps/{map_id}?token={jwt_token} Example: ws://localhost:8000/ws/maps/{map_id}?token={jwt_token}
Example: ws://localhost:8000/ws/maps/{map_id}?share_token={share_token} Example: ws://localhost:8000/ws/maps/{map_id}?share_token={share_token}
""" """
# Verify access to the map # Accept the connection first
user = None await websocket.accept()
if token:
try: # Create a temporary DB session just for authentication
user = get_user_from_token(token, db) # This session will be closed immediately after checking access
except Exception as e: db = SessionLocal()
logger.error(f"Invalid token: {e}") try:
await websocket.close(code=1008, reason="Invalid token") # Verify access to the map
user = None
if token:
try:
user = get_user_from_token(token, db)
except Exception as e:
logger.error(f"Invalid token: {e}")
await websocket.close(code=1008, reason="Invalid token")
return
# Check map access
has_access, permission = check_map_access(db, map_id, user, share_token)
if not has_access:
await websocket.close(code=1008, reason="Access denied")
return return
# Check map access # Store permission for the connection
has_access, permission = check_map_access(db, map_id, user, share_token) permission_value = permission.value
if not has_access: finally:
await websocket.close(code=1008, reason="Access denied") # CRITICAL: Close the DB session immediately after authentication
return db.close()
await manager.connect(websocket, map_id) # Add to connection manager (don't call accept again)
manager.active_connections.setdefault(str(map_id), set()).add(websocket)
logger.info(f"Client connected to map {map_id}. Total connections: {len(manager.active_connections[str(map_id)])}")
try: try:
# Send initial connection message # Send initial connection message
@@ -57,7 +70,7 @@ async def websocket_endpoint(
"type": "connected", "type": "connected",
"data": { "data": {
"map_id": str(map_id), "map_id": str(map_id),
"permission": permission.value "permission": permission_value
} }
}) })

View File

@@ -6,13 +6,11 @@ from fastapi import HTTPException, status
from geoalchemy2.shape import from_shape, to_shape from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import shape, Point, LineString from shapely.geometry import shape, Point, LineString
import json import json
import asyncio
from app.models.map_item import MapItem from app.models.map_item import MapItem
from app.models.user import User from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate from app.schemas.map_item import MapItemCreate, MapItemUpdate
from app.services.map_service import get_map_by_id from app.services.map_service import get_map_by_id
from app.websocket.connection_manager import manager
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]: def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
@@ -60,19 +58,6 @@ def geography_to_geojson(geography) -> dict:
return json.loads(json.dumps(geom.__geo_interface__)) return json.loads(json.dumps(geom.__geo_interface__))
def item_to_dict(item: MapItem) -> dict:
"""Convert MapItem to JSON-serializable dict for WebSocket broadcast."""
return {
"id": str(item.id),
"map_id": str(item.map_id),
"type": item.type,
"geometry": geography_to_geojson(item.geometry),
"properties": item.properties,
"created_at": item.created_at.isoformat(),
"updated_at": item.updated_at.isoformat()
}
def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem: def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem:
"""Create a new map item.""" """Create a new map item."""
# Verify user has access to the map # Verify user has access to the map
@@ -108,14 +93,6 @@ def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: U
print(f"Updating port connections for end device: {end_device_id}") print(f"Updating port connections for end device: {end_device_id}")
update_device_connections(db, UUID(end_device_id), item.id) update_device_connections(db, UUID(end_device_id), item.id)
# Broadcast item creation to WebSocket clients
try:
loop = asyncio.get_event_loop()
loop.create_task(manager.send_item_created(map_id, item_to_dict(item)))
except RuntimeError:
# No event loop running, skip WebSocket broadcast
pass
return item return item
@@ -178,14 +155,6 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user:
db.commit() db.commit()
db.refresh(item) db.refresh(item)
# Broadcast item update to WebSocket clients
try:
loop = asyncio.get_event_loop()
loop.create_task(manager.send_item_updated(item.map_id, item_to_dict(item)))
except RuntimeError:
# No event loop running, skip WebSocket broadcast
pass
return item return item
@@ -227,14 +196,6 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
db.delete(item) db.delete(item)
db.commit() db.commit()
# Broadcast item deletion to WebSocket clients
try:
loop = asyncio.get_event_loop()
loop.create_task(manager.send_item_deleted(map_id, deleted_item_id))
except RuntimeError:
# No event loop running, skip WebSocket broadcast
pass
def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None: def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None:
"""Remove cable connection from device's connections array.""" """Remove cable connection from device's connections array."""

View File

@@ -37,9 +37,14 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
}, [onClose]); }, [onClose]);
const handleDelete = async () => { const handleDelete = async () => {
console.log('DELETE BUTTON CLICKED - Starting delete process');
console.log('Item to delete:', { id: item.id, type: item.type, name: item.properties.name });
console.log('Delete connected cables:', deleteConnectedCables);
try { try {
// If device with connections and user wants to delete connected cables // If device with connections and user wants to delete connected cables
if (isDevice && deleteConnectedCables && hasConnections) { if (isDevice && deleteConnectedCables && hasConnections) {
console.log('Deleting connected cables first...');
// First delete all connected cables // First delete all connected cables
const { mapItemService: itemService } = await import('../../services/mapItemService'); const { mapItemService: itemService } = await import('../../services/mapItemService');
const allItems = await itemService.getMapItems(item.map_id); const allItems = await itemService.getMapItems(item.map_id);
@@ -50,18 +55,27 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
i.type === 'cable' && connectedCableIds.includes(i.id) i.type === 'cable' && connectedCableIds.includes(i.id)
); );
console.log(`Found ${cablesToDelete.length} cables to delete:`, cablesToDelete.map(c => c.id));
// Delete each cable // Delete each cable
for (const cable of cablesToDelete) { for (const cable of cablesToDelete) {
console.log(`Deleting cable ${cable.id}...`);
await itemService.deleteMapItem(item.map_id, cable.id); await itemService.deleteMapItem(item.map_id, cable.id);
console.log(`Cable ${cable.id} deleted successfully`);
} }
} }
// Delete the device/item itself // Delete the device/item itself
console.log(`Deleting main item ${item.id}...`);
await mapItemService.deleteMapItem(item.map_id, item.id); await mapItemService.deleteMapItem(item.map_id, item.id);
console.log(`Item ${item.id} deleted successfully`);
onUpdate(); onUpdate();
onClose(); onClose();
console.log('Delete process completed successfully');
} catch (error) { } catch (error) {
console.error('Failed to delete item:', error); console.error('Failed to delete item:', error);
console.error('Error details:', { error, item_id: item.id, map_id: item.map_id });
alert('Failed to delete item'); alert('Failed to delete item');
} }
}; };

View File

@@ -54,8 +54,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
}); });
setNewUserId(''); setNewUserId('');
await loadShares(); await loadShares();
alert('Map shared successfully!');
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to share map'); console.error('Share error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to share map';
alert(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -68,8 +71,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
permission: newLinkPermission, permission: newLinkPermission,
}); });
await loadLinks(); await loadLinks();
alert('Share link created successfully!');
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to create share link'); console.error('Create link error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to create share link';
alert(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -82,7 +88,9 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
await mapShareService.revokeShare(mapId, shareId); await mapShareService.revokeShare(mapId, shareId);
await loadShares(); await loadShares();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to revoke share'); console.error('Revoke share error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to revoke share';
alert(message);
} }
}; };
@@ -93,7 +101,9 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
await mapShareService.deleteShareLink(mapId, linkId); await mapShareService.deleteShareLink(mapId, linkId);
await loadLinks(); await loadLinks();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to delete link'); console.error('Delete link error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to delete link';
alert(message);
} }
}; };

View File

@@ -29,7 +29,24 @@ export function useMapWebSocket({
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0); const reconnectAttemptsRef = useRef(0);
// Use refs for callbacks to avoid reconnecting when they change
const onItemCreatedRef = useRef(onItemCreated);
const onItemUpdatedRef = useRef(onItemUpdated);
const onItemDeletedRef = useRef(onItemDeleted);
const onConnectedRef = useRef(onConnected);
// Update refs when callbacks change
useEffect(() => { useEffect(() => {
onItemCreatedRef.current = onItemCreated;
onItemUpdatedRef.current = onItemUpdated;
onItemDeletedRef.current = onItemDeleted;
onConnectedRef.current = onConnected;
}, [onItemCreated, onItemUpdated, onItemDeleted, onConnected]);
useEffect(() => {
// Skip WebSocket if no mapId
if (!mapId) return;
const connect = () => { const connect = () => {
// Get the token for authenticated users // Get the token for authenticated users
const token = authService.getAccessToken(); const token = authService.getAccessToken();
@@ -72,19 +89,19 @@ export function useMapWebSocket({
switch (message.type) { switch (message.type) {
case 'connected': case 'connected':
setPermission(message.data.permission); setPermission(message.data.permission);
onConnected?.(message.data); onConnectedRef.current?.(message.data);
break; break;
case 'item_created': case 'item_created':
onItemCreated?.(message.data); onItemCreatedRef.current?.(message.data);
break; break;
case 'item_updated': case 'item_updated':
onItemUpdated?.(message.data); onItemUpdatedRef.current?.(message.data);
break; break;
case 'item_deleted': case 'item_deleted':
onItemDeleted?.(message.data.id); onItemDeletedRef.current?.(message.data.id);
break; break;
default: default:
@@ -132,7 +149,7 @@ export function useMapWebSocket({
wsRef.current = null; wsRef.current = null;
} }
}; };
}, [mapId, shareToken, onItemCreated, onItemUpdated, onItemDeleted, onConnected]); }, [mapId, shareToken]); // Only reconnect when mapId or shareToken changes
return { return {
isConnected, isConnected,