mirror of
https://github.com/Freika/dawarich.git
synced 2025-12-30 09:49:40 -06:00
Implement real-time family member location updates via ActionCable
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -4,25 +4,23 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [0.33.1]
|
||||
# [0.34.0] - 2025-10-10
|
||||
|
||||
## The Family release
|
||||
|
||||
In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time.
|
||||
|
||||
## Added
|
||||
|
||||
- Users can now create family groups and invite members to join.
|
||||
|
||||
|
||||
# [0.33.1] - 2025-10-07
|
||||
|
||||
## Changed
|
||||
|
||||
- On the Trip page, instead of list of visited countries, a number of them is being shown. Clicking on it opens a modal with a list of countries visited during the trip. #1731
|
||||
|
||||
- **Family Features**: Complete family management system allowing users to create family groups, invite members, and share location data. Features include:
|
||||
- Family creation and management with role-based permissions (owner/member)
|
||||
- Email-based invitation system with expiration and security controls
|
||||
- Comprehensive authorization and access control via Pundit policies
|
||||
- Performance-optimized database schema with concurrent indexes
|
||||
- Feature gating for cloud vs self-hosted deployments
|
||||
- Background job processing for email delivery and cleanup
|
||||
- Interactive UI with real-time form validation and animated feedback
|
||||
- Complete test coverage including unit, integration, and system tests
|
||||
- Comprehensive error handling with user-friendly messages
|
||||
- Full API documentation and deployment guides
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- `GET /api/v1/stats` endpoint now returns correct 0 instead of null if no points were tracked in the requested period.
|
||||
|
||||
@@ -163,4 +163,27 @@
|
||||
|
||||
.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Family member marker pulse animation for recent updates */
|
||||
@keyframes family-marker-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.family-member-marker-recent {
|
||||
animation: family-marker-pulse 2s infinite;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.family-member-marker-recent .leaflet-marker-icon > div {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
border-radius: 50%;
|
||||
}
|
||||
20
app/channels/family_locations_channel.rb
Normal file
20
app/channels/family_locations_channel.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FamilyLocationsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
return reject unless family_feature_enabled?
|
||||
return reject unless current_user.in_family?
|
||||
|
||||
stream_for current_user.family
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Any cleanup needed when channel is unsubscribed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def family_feature_enabled?
|
||||
DawarichSettings.family_feature_enabled?
|
||||
end
|
||||
end
|
||||
24
app/javascript/channels/family_locations_channel.js
Normal file
24
app/javascript/channels/family_locations_channel.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import consumer from "./consumer"
|
||||
|
||||
// Only create subscription if family feature is enabled
|
||||
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]');
|
||||
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {};
|
||||
|
||||
if (features.family) {
|
||||
consumer.subscriptions.create("FamilyLocationsChannel", {
|
||||
connected() {
|
||||
// Connected to family locations channel
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
// Disconnected from family locations channel
|
||||
},
|
||||
|
||||
received(data) {
|
||||
// Pass data to family members controller if it exists
|
||||
if (window.familyMembersController) {
|
||||
window.familyMembersController.updateSingleMemberLocation(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
import "notifications_channel"
|
||||
import "points_channel"
|
||||
import "imports_channel"
|
||||
import "family_locations_channel"
|
||||
|
||||
@@ -52,7 +52,11 @@ export default class extends Controller {
|
||||
|
||||
// Initialize family member markers layer
|
||||
this.familyMarkersLayer = L.layerGroup();
|
||||
this.familyMemberLocations = []; // Initialize as empty, will be fetched via API
|
||||
this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates
|
||||
this.familyMarkers = {}; // Store marker references by user_id
|
||||
|
||||
// Expose controller globally for ActionCable channel
|
||||
window.familyMembersController = this;
|
||||
|
||||
// Add to layer control immediately (layer will be empty until data is fetched)
|
||||
this.addToLayerControl();
|
||||
@@ -67,16 +71,19 @@ export default class extends Controller {
|
||||
this.familyMarkersLayer.clearLayers();
|
||||
}
|
||||
|
||||
// Clear marker references
|
||||
this.familyMarkers = {};
|
||||
|
||||
// Only proceed if family feature is enabled and we have family member locations
|
||||
if (!this.featuresValue.family ||
|
||||
!this.familyMemberLocations ||
|
||||
this.familyMemberLocations.length === 0) {
|
||||
Object.keys(this.familyMemberLocations).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
|
||||
this.familyMemberLocations.forEach((location) => {
|
||||
Object.values(this.familyMemberLocations).forEach((location) => {
|
||||
if (!location || !location.latitude || !location.longitude) {
|
||||
return;
|
||||
}
|
||||
@@ -84,13 +91,17 @@ export default class extends Controller {
|
||||
// Get the first letter of the email or use '?' as fallback
|
||||
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
|
||||
|
||||
// Check if this is a recent update (within last 5 minutes)
|
||||
const isRecent = this.isRecentUpdate(location.updated_at);
|
||||
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
|
||||
|
||||
// Create a distinct marker for family members with email initial
|
||||
const familyMarker = L.marker([location.latitude, location.longitude], {
|
||||
icon: L.divIcon({
|
||||
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
className: 'family-member-marker'
|
||||
className: markerClass
|
||||
})
|
||||
});
|
||||
|
||||
@@ -120,6 +131,9 @@ export default class extends Controller {
|
||||
|
||||
this.familyMarkersLayer.addLayer(familyMarker);
|
||||
|
||||
// Store marker reference by user_id for efficient updates
|
||||
this.familyMarkers[location.user_id] = familyMarker;
|
||||
|
||||
// Add to bounds array for auto-zoom
|
||||
bounds.push([location.latitude, location.longitude]);
|
||||
});
|
||||
@@ -128,6 +142,102 @@ export default class extends Controller {
|
||||
this.familyMemberBounds = bounds;
|
||||
}
|
||||
|
||||
// Update a single family member's location in real-time
|
||||
updateSingleMemberLocation(locationData) {
|
||||
if (!this.featuresValue.family) return;
|
||||
if (!locationData || !locationData.user_id) return;
|
||||
|
||||
// Update stored location data
|
||||
this.familyMemberLocations[locationData.user_id] = locationData;
|
||||
|
||||
// If the Family Members layer is not currently visible, just store the data
|
||||
if (!this.map.hasLayer(this.familyMarkersLayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing marker for this user
|
||||
const existingMarker = this.familyMarkers[locationData.user_id];
|
||||
|
||||
if (existingMarker) {
|
||||
// Update existing marker position and content
|
||||
existingMarker.setLatLng([locationData.latitude, locationData.longitude]);
|
||||
|
||||
// Update marker icon with pulse animation for recent updates
|
||||
const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?';
|
||||
const isRecent = this.isRecentUpdate(locationData.updated_at);
|
||||
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
|
||||
|
||||
const newIcon = L.divIcon({
|
||||
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
className: markerClass
|
||||
});
|
||||
existingMarker.setIcon(newIcon);
|
||||
|
||||
// Update tooltip content
|
||||
const lastSeen = new Date(locationData.updated_at).toLocaleString();
|
||||
const tooltipContent = this.createTooltipContent(lastSeen);
|
||||
existingMarker.setTooltipContent(tooltipContent);
|
||||
|
||||
// Update popup content
|
||||
const popupContent = this.createPopupContent(locationData, lastSeen);
|
||||
existingMarker.setPopupContent(popupContent);
|
||||
} else {
|
||||
// Create new marker for this user
|
||||
this.createSingleFamilyMarker(locationData);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if location was updated within the last 5 minutes
|
||||
isRecentUpdate(updatedAt) {
|
||||
const updateTime = new Date(updatedAt);
|
||||
const now = new Date();
|
||||
const diffMinutes = (now - updateTime) / 1000 / 60;
|
||||
return diffMinutes < 5;
|
||||
}
|
||||
|
||||
// Create a marker for a single family member
|
||||
createSingleFamilyMarker(location) {
|
||||
if (!location || !location.latitude || !location.longitude) return;
|
||||
|
||||
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
|
||||
const isRecent = this.isRecentUpdate(location.updated_at);
|
||||
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
|
||||
|
||||
const familyMarker = L.marker([location.latitude, location.longitude], {
|
||||
icon: L.divIcon({
|
||||
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
className: markerClass
|
||||
})
|
||||
});
|
||||
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString();
|
||||
|
||||
const tooltipContent = this.createTooltipContent(lastSeen);
|
||||
familyMarker.bindTooltip(tooltipContent, {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -12],
|
||||
className: 'family-member-tooltip'
|
||||
});
|
||||
|
||||
const popupContent = this.createPopupContent(location, lastSeen);
|
||||
familyMarker.bindPopup(popupContent);
|
||||
|
||||
familyMarker.on('popupopen', () => {
|
||||
familyMarker.closeTooltip();
|
||||
});
|
||||
familyMarker.on('popupclose', () => {
|
||||
familyMarker.openTooltip();
|
||||
});
|
||||
|
||||
this.familyMarkersLayer.addLayer(familyMarker);
|
||||
this.familyMarkers[location.user_id] = familyMarker;
|
||||
}
|
||||
|
||||
createTooltipContent(lastSeen) {
|
||||
return `Last updated: ${lastSeen}`;
|
||||
}
|
||||
@@ -202,11 +312,10 @@ export default class extends Controller {
|
||||
// Listen for when the Family Members layer is added
|
||||
this.map.on('overlayadd', (event) => {
|
||||
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
|
||||
console.log('Family Members layer enabled - refreshing locations and zooming to fit');
|
||||
this.refreshFamilyLocations();
|
||||
|
||||
// Zoom to show all family members
|
||||
this.zoomToFitAllMembers();
|
||||
// Refresh locations and zoom after data is loaded
|
||||
this.refreshFamilyLocations().then(() => {
|
||||
this.zoomToFitAllMembers();
|
||||
});
|
||||
|
||||
// Set up periodic refresh while layer is active
|
||||
this.startPeriodicRefresh();
|
||||
@@ -245,7 +354,7 @@ export default class extends Controller {
|
||||
// Clear any existing refresh interval
|
||||
this.stopPeriodicRefresh();
|
||||
|
||||
// Refresh family locations every 30 seconds while layer is active
|
||||
// Refresh family locations every 60 seconds while layer is active (as fallback to real-time)
|
||||
this.refreshInterval = setInterval(() => {
|
||||
if (this.map && this.map.hasLayer(this.familyMarkersLayer)) {
|
||||
this.refreshFamilyLocations();
|
||||
@@ -253,7 +362,7 @@ export default class extends Controller {
|
||||
// Layer is no longer active, stop refreshing
|
||||
this.stopPeriodicRefresh();
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
}, 60000); // 60 seconds (real-time updates via ActionCable are primary)
|
||||
}
|
||||
|
||||
stopPeriodicRefresh() {
|
||||
@@ -265,12 +374,23 @@ export default class extends Controller {
|
||||
|
||||
// Method to manually update family member locations (for API calls)
|
||||
updateFamilyLocations(locations) {
|
||||
this.familyMemberLocations = locations;
|
||||
// Convert array to object keyed by user_id
|
||||
if (Array.isArray(locations)) {
|
||||
this.familyMemberLocations = {};
|
||||
locations.forEach(location => {
|
||||
if (location.user_id) {
|
||||
this.familyMemberLocations[location.user_id] = location;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.familyMemberLocations = locations;
|
||||
}
|
||||
|
||||
this.createFamilyMarkers();
|
||||
|
||||
// Dispatch event for other controllers that might be interested
|
||||
document.dispatchEvent(new CustomEvent('family:locations:updated', {
|
||||
detail: { locations: locations }
|
||||
detail: { locations: this.familyMemberLocations }
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -361,6 +481,6 @@ export default class extends Controller {
|
||||
|
||||
// Get family marker count
|
||||
getFamilyMemberCount() {
|
||||
return this.familyMemberLocations ? this.familyMemberLocations.length : 0;
|
||||
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
|
||||
}
|
||||
}
|
||||
@@ -75,24 +75,49 @@ class Point < ApplicationRecord
|
||||
|
||||
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
|
||||
def broadcast_coordinates
|
||||
return unless user.safe_settings.live_map_enabled
|
||||
if user.safe_settings.live_map_enabled
|
||||
PointsChannel.broadcast_to(
|
||||
user,
|
||||
[
|
||||
lat,
|
||||
lon,
|
||||
battery.to_s,
|
||||
altitude.to_s,
|
||||
timestamp.to_s,
|
||||
velocity.to_s,
|
||||
id.to_s,
|
||||
country_name.to_s
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
PointsChannel.broadcast_to(
|
||||
user,
|
||||
[
|
||||
lat,
|
||||
lon,
|
||||
battery.to_s,
|
||||
altitude.to_s,
|
||||
timestamp.to_s,
|
||||
velocity.to_s,
|
||||
id.to_s,
|
||||
country_name.to_s
|
||||
]
|
||||
)
|
||||
broadcast_to_family if should_broadcast_to_family?
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def should_broadcast_to_family?
|
||||
return false unless DawarichSettings.family_feature_enabled?
|
||||
return false unless user.in_family?
|
||||
return false unless user.family_sharing_enabled?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def broadcast_to_family
|
||||
FamilyLocationsChannel.broadcast_to(
|
||||
user.family,
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
email_initial: user.email.first.upcase,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: timestamp.to_i,
|
||||
updated_at: Time.zone.at(timestamp.to_i).iso8601
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def set_country
|
||||
self.country_id = found_in_country&.id
|
||||
save! if changed?
|
||||
|
||||
@@ -89,18 +89,16 @@ module Families
|
||||
Notification.create!(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Welcome to Family',
|
||||
title: 'Welcome to Family!',
|
||||
content: "You've joined the family '#{invitation.family.name}'"
|
||||
)
|
||||
end
|
||||
|
||||
def send_owner_notification
|
||||
return unless defined?(Notification)
|
||||
|
||||
Notification.create!(
|
||||
user: invitation.family.creator,
|
||||
kind: :info,
|
||||
title: 'New Family Member',
|
||||
title: 'New Family Member!',
|
||||
content: "#{user.email} has joined your family"
|
||||
)
|
||||
rescue StandardError => e
|
||||
|
||||
@@ -94,8 +94,6 @@ module Families
|
||||
end
|
||||
|
||||
def send_notification
|
||||
return unless defined?(Notification)
|
||||
|
||||
Notification.create!(
|
||||
user: user,
|
||||
kind: :info,
|
||||
|
||||
@@ -90,8 +90,6 @@ module Families
|
||||
end
|
||||
|
||||
def send_notification
|
||||
return unless defined?(Notification)
|
||||
|
||||
Notification.create!(
|
||||
user: invited_by,
|
||||
kind: :info,
|
||||
|
||||
@@ -96,8 +96,6 @@ module Families
|
||||
end
|
||||
|
||||
def send_notifications
|
||||
return unless defined?(Notification)
|
||||
|
||||
if removing_self?
|
||||
send_self_removal_notifications
|
||||
else
|
||||
|
||||
@@ -23,5 +23,6 @@ pin 'leaflet-draw' # @1.0.4
|
||||
pin 'notifications_channel', to: 'channels/notifications_channel.js'
|
||||
pin 'points_channel', to: 'channels/points_channel.js'
|
||||
pin 'imports_channel', to: 'channels/imports_channel.js'
|
||||
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
|
||||
pin 'trix'
|
||||
pin '@rails/actiontext', to: 'actiontext.esm.js'
|
||||
|
||||
@@ -26,10 +26,10 @@ RSpec.describe Families::AcceptInvitation do
|
||||
it 'sends notifications to both parties' do
|
||||
expect { service.call }.to change(Notification, :count).by(2)
|
||||
|
||||
user_notification = Notification.find_by(user: invitee, title: 'Welcome to Family')
|
||||
user_notification = Notification.find_by(user: invitee, title: 'Welcome to Family!')
|
||||
expect(user_notification).to be_present
|
||||
|
||||
owner_notification = Notification.find_by(user: family.creator, title: 'New Family Member')
|
||||
owner_notification = Notification.find_by(user: family.creator, title: 'New Family Member!')
|
||||
expect(owner_notification).to be_present
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user