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;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_pass http://python:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,15 +15,24 @@ server {
}
location /ws/ {
proxy_pass http://backend:8000/ws/;
proxy_pass http://python:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_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 / {
# In development, proxy to Vite dev server
proxy_pass http://frontend:3000;
proxy_pass http://node:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

View File

@@ -1,7 +1,6 @@
from app.models.user import User
from app.models.map import Map
from app.models.map_item import MapItem
from app.models.share import Share
from app.models.session import Session
from app.models.map_share import MapShare, MapShareLink
__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)
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)
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)
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)
@@ -34,7 +34,7 @@ class MapShareLink(Base):
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)
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)
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

View File

@@ -10,6 +10,7 @@ from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate, MapItemResponse
from app.services import item_service
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"])
@@ -50,7 +51,12 @@ async def create_map_item(
):
"""Create a new map item."""
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)
@@ -75,7 +81,12 @@ async def update_map_item(
):
"""Update a map item."""
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)
@@ -87,4 +98,8 @@ async def delete_map_item(
):
"""Delete a map item."""
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

View File

@@ -1,11 +1,10 @@
"""WebSocket routes for real-time updates."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlalchemy.orm import Session
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from uuid import UUID
from typing import Optional
import logging
from app.database import get_db
from app.database import SessionLocal
from app.websocket.connection_manager import manager
from app.services.map_share_service import check_map_access
from app.dependencies import get_user_from_token
@@ -20,11 +19,10 @@ async def websocket_endpoint(
websocket: WebSocket,
map_id: UUID,
token: Optional[str] = Query(None),
share_token: Optional[str] = Query(None),
db: Session = Depends(get_db)
share_token: Optional[str] = Query(None)
):
"""
WebSocket endpoint for real-time map updates.
WebSocket endpoint for real-time updates.
Clients can connect using:
- JWT token (authenticated users)
@@ -33,6 +31,13 @@ async def websocket_endpoint(
Example: ws://localhost:8000/ws/maps/{map_id}?token={jwt_token}
Example: ws://localhost:8000/ws/maps/{map_id}?share_token={share_token}
"""
# Accept the connection first
await websocket.accept()
# Create a temporary DB session just for authentication
# This session will be closed immediately after checking access
db = SessionLocal()
try:
# Verify access to the map
user = None
if token:
@@ -49,7 +54,15 @@ async def websocket_endpoint(
await websocket.close(code=1008, reason="Access denied")
return
await manager.connect(websocket, map_id)
# Store permission for the connection
permission_value = permission.value
finally:
# CRITICAL: Close the DB session immediately after authentication
db.close()
# 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:
# Send initial connection message
@@ -57,7 +70,7 @@ async def websocket_endpoint(
"type": "connected",
"data": {
"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 shapely.geometry import shape, Point, LineString
import json
import asyncio
from app.models.map_item import MapItem
from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate
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]:
@@ -60,19 +58,6 @@ def geography_to_geojson(geography) -> dict:
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:
"""Create a new map item."""
# 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}")
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
@@ -178,14 +155,6 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user:
db.commit()
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
@@ -227,14 +196,6 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
db.delete(item)
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:
"""Remove cable connection from device's connections array."""

View File

@@ -37,9 +37,14 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
}, [onClose]);
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 {
// If device with connections and user wants to delete connected cables
if (isDevice && deleteConnectedCables && hasConnections) {
console.log('Deleting connected cables first...');
// First delete all connected cables
const { mapItemService: itemService } = await import('../../services/mapItemService');
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)
);
console.log(`Found ${cablesToDelete.length} cables to delete:`, cablesToDelete.map(c => c.id));
// Delete each cable
for (const cable of cablesToDelete) {
console.log(`Deleting cable ${cable.id}...`);
await itemService.deleteMapItem(item.map_id, cable.id);
console.log(`Cable ${cable.id} deleted successfully`);
}
}
// Delete the device/item itself
console.log(`Deleting main item ${item.id}...`);
await mapItemService.deleteMapItem(item.map_id, item.id);
console.log(`Item ${item.id} deleted successfully`);
onUpdate();
onClose();
console.log('Delete process completed successfully');
} catch (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');
}
};

View File

@@ -54,8 +54,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
});
setNewUserId('');
await loadShares();
alert('Map shared successfully!');
} 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 {
setLoading(false);
}
@@ -68,8 +71,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
permission: newLinkPermission,
});
await loadLinks();
alert('Share link created successfully!');
} 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 {
setLoading(false);
}
@@ -82,7 +88,9 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
await mapShareService.revokeShare(mapId, shareId);
await loadShares();
} 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 loadLinks();
} 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 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(() => {
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 = () => {
// Get the token for authenticated users
const token = authService.getAccessToken();
@@ -72,19 +89,19 @@ export function useMapWebSocket({
switch (message.type) {
case 'connected':
setPermission(message.data.permission);
onConnected?.(message.data);
onConnectedRef.current?.(message.data);
break;
case 'item_created':
onItemCreated?.(message.data);
onItemCreatedRef.current?.(message.data);
break;
case 'item_updated':
onItemUpdated?.(message.data);
onItemUpdatedRef.current?.(message.data);
break;
case 'item_deleted':
onItemDeleted?.(message.data.id);
onItemDeletedRef.current?.(message.data.id);
break;
default:
@@ -132,7 +149,7 @@ export function useMapWebSocket({
wsRef.current = null;
}
};
}, [mapId, shareToken, onItemCreated, onItemUpdated, onItemDeleted, onConnected]);
}, [mapId, shareToken]); // Only reconnect when mapId or shareToken changes
return {
isConnected,