mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Complete phase 5
This commit is contained in:
parent
e17f732706
commit
1f67e889e3
30 changed files with 1618 additions and 71 deletions
|
|
@ -4,3 +4,6 @@ DATABASE_PASSWORD=password
|
||||||
DATABASE_NAME=dawarich_development
|
DATABASE_NAME=dawarich_development
|
||||||
DATABASE_PORT=5432
|
DATABASE_PORT=5432
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Fix for macOS fork() issues with Sidekiq
|
||||||
|
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
|
||||||
|
|
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
# [UNRELEASED]
|
# [UNRELEASED]
|
||||||
|
|
||||||
|
- **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
|
## Fixed
|
||||||
|
|
||||||
- Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745
|
- Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -101,3 +101,63 @@
|
||||||
content: '✅';
|
content: '✅';
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flash message animations */
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToRight {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Family feature specific styles */
|
||||||
|
.family-member-card {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-member-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card {
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-invitation-form {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading states */
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class FamiliesController < ApplicationController
|
class FamiliesController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_family_feature_enabled!
|
||||||
before_action :set_family, only: %i[show edit update destroy leave]
|
before_action :set_family, only: %i[show edit update destroy leave]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
@ -11,8 +12,13 @@ class FamiliesController < ApplicationController
|
||||||
def show
|
def show
|
||||||
authorize @family
|
authorize @family
|
||||||
|
|
||||||
@members = @family.members.includes(:family_membership)
|
# Use optimized family methods for better performance
|
||||||
@pending_invitations = @family.family_invitations.active
|
@members = @family.members.includes(:family_membership).order(:email)
|
||||||
|
@pending_invitations = @family.active_invitations.order(:created_at)
|
||||||
|
|
||||||
|
# Use cached counts to avoid extra queries
|
||||||
|
@member_count = @family.member_count
|
||||||
|
@can_invite = @family.can_add_members?
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
|
@ -32,9 +38,19 @@ class FamiliesController < ApplicationController
|
||||||
else
|
else
|
||||||
@family = Family.new(family_params)
|
@family = Family.new(family_params)
|
||||||
|
|
||||||
service.errors.each do |attribute, message|
|
# Handle validation errors
|
||||||
@family.errors.add(attribute, message)
|
if service.errors.any?
|
||||||
|
service.errors.each do |attribute, message|
|
||||||
|
@family.errors.add(attribute, message)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle service-level errors
|
||||||
|
if service.error_message.present?
|
||||||
|
@family.errors.add(:base, service.error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
flash.now[:alert] = service.error_message || 'Failed to create family'
|
||||||
render :new, status: :unprocessable_content
|
render :new, status: :unprocessable_content
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -82,6 +98,12 @@ class FamiliesController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def ensure_family_feature_enabled!
|
||||||
|
unless DawarichSettings.family_feature_enabled?
|
||||||
|
redirect_to root_path, alert: 'Family feature is not available'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def set_family
|
def set_family
|
||||||
@family = current_user.family
|
@family = current_user.family
|
||||||
redirect_to families_path unless @family
|
redirect_to families_path unless @family
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class FamilyInvitationsController < ApplicationController
|
class FamilyInvitationsController < ApplicationController
|
||||||
before_action :authenticate_user!, except: %i[show accept]
|
before_action :authenticate_user!, except: %i[show accept]
|
||||||
|
before_action :ensure_family_feature_enabled!, except: %i[show accept]
|
||||||
before_action :set_family, except: %i[show accept]
|
before_action :set_family, except: %i[show accept]
|
||||||
before_action :set_invitation, only: %i[show accept destroy]
|
before_action :set_invitation, only: %i[show accept destroy]
|
||||||
|
|
||||||
|
|
@ -43,27 +44,62 @@ class FamilyInvitationsController < ApplicationController
|
||||||
def accept
|
def accept
|
||||||
authenticate_user!
|
authenticate_user!
|
||||||
|
|
||||||
|
# Additional validations before attempting to accept
|
||||||
|
unless @invitation.pending?
|
||||||
|
redirect_to root_path, alert: 'This invitation has already been processed'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if @invitation.expired?
|
||||||
|
redirect_to root_path, alert: 'This invitation has expired'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if @invitation.email != current_user.email
|
||||||
|
redirect_to root_path, alert: 'This invitation is not for your email address'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
service = Families::AcceptInvitation.new(
|
service = Families::AcceptInvitation.new(
|
||||||
invitation: @invitation,
|
invitation: @invitation,
|
||||||
user: current_user
|
user: current_user
|
||||||
)
|
)
|
||||||
|
|
||||||
if service.call
|
if service.call
|
||||||
redirect_to family_path(current_user.reload.family), notice: 'Welcome to the family!'
|
redirect_to family_path(current_user.reload.family),
|
||||||
|
notice: "Welcome to #{@invitation.family.name}! You're now part of the family."
|
||||||
else
|
else
|
||||||
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
||||||
end
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error accepting family invitation: #{e.message}"
|
||||||
|
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
authorize @family, :manage_invitations?
|
authorize @family, :manage_invitations?
|
||||||
|
|
||||||
@invitation.update!(status: :cancelled)
|
if @invitation.update(status: :cancelled)
|
||||||
redirect_to family_path(@family), notice: 'Invitation cancelled'
|
redirect_to family_path(@family),
|
||||||
|
notice: "Invitation to #{@invitation.email} has been cancelled"
|
||||||
|
else
|
||||||
|
redirect_to family_path(@family),
|
||||||
|
alert: 'Failed to cancel invitation. Please try again'
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error cancelling family invitation: #{e.message}"
|
||||||
|
redirect_to family_path(@family),
|
||||||
|
alert: 'An unexpected error occurred while cancelling the invitation'
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def ensure_family_feature_enabled!
|
||||||
|
unless DawarichSettings.family_feature_enabled?
|
||||||
|
redirect_to root_path, alert: 'Family feature is not available'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def set_family
|
def set_family
|
||||||
@family = current_user.family
|
@family = current_user.family
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class FamilyMembershipsController < ApplicationController
|
class FamilyMembershipsController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_family_feature_enabled!
|
||||||
before_action :set_family
|
before_action :set_family
|
||||||
before_action :set_membership, only: %i[show destroy]
|
before_action :set_membership, only: %i[show destroy]
|
||||||
|
|
||||||
|
|
@ -30,6 +31,12 @@ class FamilyMembershipsController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def ensure_family_feature_enabled!
|
||||||
|
unless DawarichSettings.family_feature_enabled?
|
||||||
|
redirect_to root_path, alert: 'Family feature is not available'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def set_family
|
def set_family
|
||||||
@family = current_user.family
|
@family = current_user.family
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,42 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def flash_alert_class(type)
|
||||||
|
case type.to_sym
|
||||||
|
when :notice, :success
|
||||||
|
'alert-success'
|
||||||
|
when :alert, :error
|
||||||
|
'alert-error'
|
||||||
|
when :warning
|
||||||
|
'alert-warning'
|
||||||
|
when :info
|
||||||
|
'alert-info'
|
||||||
|
else
|
||||||
|
'alert-info'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def flash_icon(type)
|
||||||
|
case type.to_sym
|
||||||
|
when :notice, :success
|
||||||
|
content_tag :svg, class: 'w-5 h-5 flex-shrink-0', fill: 'currentColor', viewBox: '0 0 20 20' do
|
||||||
|
content_tag :path, '', fill_rule: 'evenodd', d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z', clip_rule: 'evenodd'
|
||||||
|
end
|
||||||
|
when :alert, :error
|
||||||
|
content_tag :svg, class: 'w-5 h-5 flex-shrink-0', fill: 'currentColor', viewBox: '0 0 20 20' do
|
||||||
|
content_tag :path, '', fill_rule: 'evenodd', d: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z', clip_rule: 'evenodd'
|
||||||
|
end
|
||||||
|
when :warning
|
||||||
|
content_tag :svg, class: 'w-5 h-5 flex-shrink-0', fill: 'currentColor', viewBox: '0 0 20 20' do
|
||||||
|
content_tag :path, '', fill_rule: 'evenodd', d: 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z', clip_rule: 'evenodd'
|
||||||
|
end
|
||||||
|
else
|
||||||
|
content_tag :svg, class: 'w-5 h-5 flex-shrink-0', fill: 'currentColor', viewBox: '0 0 20 20' do
|
||||||
|
content_tag :path, '', fill_rule: 'evenodd', d: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z', clip_rule: 'evenodd'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def year_timespan(year)
|
def year_timespan(year)
|
||||||
start_at = DateTime.new(year).beginning_of_year.strftime('%Y-%m-%dT%H:%M')
|
start_at = DateTime.new(year).beginning_of_year.strftime('%Y-%m-%dT%H:%M')
|
||||||
end_at = DateTime.new(year).end_of_year.strftime('%Y-%m-%dT%H:%M')
|
end_at = DateTime.new(year).end_of_year.strftime('%Y-%m-%dT%H:%M')
|
||||||
|
|
|
||||||
84
app/helpers/families_helper.rb
Normal file
84
app/helpers/families_helper.rb
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module FamiliesHelper
|
||||||
|
def family_member_role_badge(membership)
|
||||||
|
case membership.role
|
||||||
|
when 'owner'
|
||||||
|
content_tag :span, 'Owner', class: 'badge badge-primary badge-sm'
|
||||||
|
when 'member'
|
||||||
|
content_tag :span, 'Member', class: 'badge badge-secondary badge-sm'
|
||||||
|
else
|
||||||
|
content_tag :span, membership.role.humanize, class: 'badge badge-ghost badge-sm'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_invitation_status_badge(invitation)
|
||||||
|
case invitation.status
|
||||||
|
when 'pending'
|
||||||
|
content_tag :span, 'Pending', class: 'badge badge-warning badge-sm'
|
||||||
|
when 'accepted'
|
||||||
|
content_tag :span, 'Accepted', class: 'badge badge-success badge-sm'
|
||||||
|
when 'expired'
|
||||||
|
content_tag :span, 'Expired', class: 'badge badge-error badge-sm'
|
||||||
|
when 'cancelled'
|
||||||
|
content_tag :span, 'Cancelled', class: 'badge badge-ghost badge-sm'
|
||||||
|
else
|
||||||
|
content_tag :span, invitation.status.humanize, class: 'badge badge-ghost badge-sm'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_capacity_warning(family)
|
||||||
|
return unless family.members.count >= Family::MAX_MEMBERS - 1
|
||||||
|
|
||||||
|
content_tag :div, class: 'alert alert-warning mt-2' do
|
||||||
|
content_tag :div do
|
||||||
|
if family.members.count >= Family::MAX_MEMBERS
|
||||||
|
'This family has reached the maximum number of members.'
|
||||||
|
else
|
||||||
|
"This family is almost full (#{family.members.count}/#{Family::MAX_MEMBERS} members)."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def invitation_expiry_warning(invitation)
|
||||||
|
return unless invitation.pending?
|
||||||
|
|
||||||
|
time_left = invitation.expires_at - Time.current
|
||||||
|
return unless time_left < 24.hours
|
||||||
|
|
||||||
|
warning_class = time_left < 1.hour ? 'alert-error' : 'alert-warning'
|
||||||
|
|
||||||
|
content_tag :div, class: "alert #{warning_class} mt-2" do
|
||||||
|
content_tag :div do
|
||||||
|
if time_left < 1.hour
|
||||||
|
'This invitation expires in less than 1 hour!'
|
||||||
|
else
|
||||||
|
"This invitation expires in #{time_ago_in_words(invitation.expires_at)}."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_member_location_status(member)
|
||||||
|
# This would integrate with location sharing when implemented
|
||||||
|
content_tag :span, class: 'text-sm text-gray-500' do
|
||||||
|
'Location sharing not implemented yet'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_creation_benefits
|
||||||
|
content_tag :div, class: 'bg-base-200 p-4 rounded-lg' do
|
||||||
|
content_tag :h3, 'Family Features:', class: 'font-semibold mb-2' do
|
||||||
|
concat content_tag(:h3, 'Family Features:', class: 'font-semibold mb-2')
|
||||||
|
concat content_tag(:ul, class: 'list-disc list-inside space-y-1 text-sm') do
|
||||||
|
concat content_tag(:li, "Share your current location with up to #{Family::MAX_MEMBERS - 1} family members")
|
||||||
|
concat content_tag(:li, 'See where your family members are right now')
|
||||||
|
concat content_tag(:li, 'Control your privacy with sharing toggles')
|
||||||
|
concat content_tag(:li, 'Invite members by email')
|
||||||
|
concat content_tag(:li, 'Secure and private - only family members can see your location')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
81
app/javascript/controllers/family_actions_controller.js
Normal file
81
app/javascript/controllers/family_actions_controller.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["confirmButton", "cancelButton"]
|
||||||
|
static values = {
|
||||||
|
action: String,
|
||||||
|
memberEmail: String,
|
||||||
|
familyName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.setupConfirmationMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
setupConfirmationMessages() {
|
||||||
|
const confirmButtons = this.element.querySelectorAll('[data-confirm]')
|
||||||
|
|
||||||
|
confirmButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
const action = button.dataset.action
|
||||||
|
const confirmMessage = this.getConfirmationMessage(action)
|
||||||
|
|
||||||
|
if (!confirm(confirmMessage)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfirmationMessage(action) {
|
||||||
|
switch(action) {
|
||||||
|
case 'leave-family':
|
||||||
|
return `Are you sure you want to leave "${this.familyNameValue}"? You'll need a new invitation to rejoin.`
|
||||||
|
case 'delete-family':
|
||||||
|
return `Are you sure you want to delete "${this.familyNameValue}"? This action cannot be undone.`
|
||||||
|
case 'remove-member':
|
||||||
|
return `Are you sure you want to remove ${this.memberEmailValue} from the family?`
|
||||||
|
case 'cancel-invitation':
|
||||||
|
return `Are you sure you want to cancel the invitation to ${this.memberEmailValue}?`
|
||||||
|
default:
|
||||||
|
return 'Are you sure you want to perform this action?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoadingState(button, action) {
|
||||||
|
const originalText = button.innerHTML
|
||||||
|
button.disabled = true
|
||||||
|
|
||||||
|
const loadingText = this.getLoadingText(action)
|
||||||
|
button.innerHTML = `
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
${loadingText}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Store original text to restore if needed
|
||||||
|
button.dataset.originalText = originalText
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoadingText(action) {
|
||||||
|
switch(action) {
|
||||||
|
case 'leave-family':
|
||||||
|
return 'Leaving family...'
|
||||||
|
case 'delete-family':
|
||||||
|
return 'Deleting family...'
|
||||||
|
case 'remove-member':
|
||||||
|
return 'Removing member...'
|
||||||
|
case 'cancel-invitation':
|
||||||
|
return 'Cancelling invitation...'
|
||||||
|
default:
|
||||||
|
return 'Processing...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirmedAction(event) {
|
||||||
|
const button = event.currentTarget
|
||||||
|
const action = button.dataset.action
|
||||||
|
|
||||||
|
this.showLoadingState(button, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/javascript/controllers/family_invitation_controller.js
Normal file
66
app/javascript/controllers/family_invitation_controller.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["form", "email", "submitButton", "errorMessage"]
|
||||||
|
static values = { maxMembers: Number, currentMembers: Number }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.validateForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
validateForm() {
|
||||||
|
const email = this.emailTarget.value.trim()
|
||||||
|
const isValid = this.isValidEmail(email) && this.canInviteMoreMembers()
|
||||||
|
|
||||||
|
this.submitButtonTarget.disabled = !isValid
|
||||||
|
|
||||||
|
if (email && !this.isValidEmail(email)) {
|
||||||
|
this.showError("Please enter a valid email address")
|
||||||
|
} else if (!this.canInviteMoreMembers()) {
|
||||||
|
this.showError(`Family is full (${this.currentMembersValue}/${this.maxMembersValue} members)`)
|
||||||
|
} else {
|
||||||
|
this.hideError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEmailInput() {
|
||||||
|
this.validateForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
return emailRegex.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
canInviteMoreMembers() {
|
||||||
|
return this.currentMembersValue < this.maxMembersValue
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
if (this.hasErrorMessageTarget) {
|
||||||
|
this.errorMessageTarget.textContent = message
|
||||||
|
this.errorMessageTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
if (this.hasErrorMessageTarget) {
|
||||||
|
this.errorMessageTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(event) {
|
||||||
|
if (!this.isValidEmail(this.emailTarget.value.trim()) || !this.canInviteMoreMembers()) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.validateForm()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
this.submitButtonTarget.disabled = true
|
||||||
|
this.submitButtonTarget.innerHTML = `
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Sending invitation...
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/javascript/controllers/flash_message_controller.js
Normal file
43
app/javascript/controllers/flash_message_controller.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
type: String,
|
||||||
|
autoDismiss: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.element.style.animation = 'slideInFromRight 0.3s ease-out forwards'
|
||||||
|
|
||||||
|
if (this.autoDismissValue) {
|
||||||
|
this.scheduleDismissal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleDismissal() {
|
||||||
|
// Auto-dismiss success/notice messages after 5 seconds
|
||||||
|
this.dismissTimeout = setTimeout(() => {
|
||||||
|
this.dismiss()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
if (this.dismissTimeout) {
|
||||||
|
clearTimeout(this.dismissTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element.style.animation = 'slideOutToRight 0.3s ease-in forwards'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.element.parentNode) {
|
||||||
|
this.element.parentNode.removeChild(this.element)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.dismissTimeout) {
|
||||||
|
clearTimeout(this.dismissTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/jobs/family_invitations_cleanup_job.rb
Normal file
26
app/jobs/family_invitations_cleanup_job.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class FamilyInvitationsCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Rails.logger.info 'Starting family invitations cleanup'
|
||||||
|
|
||||||
|
# Update expired invitations
|
||||||
|
expired_count = FamilyInvitation.where(status: :pending)
|
||||||
|
.where('expires_at < ?', Time.current)
|
||||||
|
.update_all(status: :expired)
|
||||||
|
|
||||||
|
Rails.logger.info "Updated #{expired_count} expired family invitations"
|
||||||
|
|
||||||
|
# Delete old expired/cancelled invitations (older than 30 days)
|
||||||
|
cleanup_threshold = 30.days.ago
|
||||||
|
deleted_count = FamilyInvitation.where(status: [:expired, :cancelled])
|
||||||
|
.where('updated_at < ?', cleanup_threshold)
|
||||||
|
.delete_all
|
||||||
|
|
||||||
|
Rails.logger.info "Deleted #{deleted_count} old family invitations"
|
||||||
|
|
||||||
|
Rails.logger.info 'Family invitations cleanup completed'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,7 +10,47 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
MAX_MEMBERS = 5
|
MAX_MEMBERS = 5
|
||||||
|
|
||||||
|
# Optimized scopes for better performance
|
||||||
|
scope :with_members, -> { includes(:members, :family_memberships) }
|
||||||
|
scope :with_pending_invitations, -> { includes(family_invitations: :invited_by) }
|
||||||
|
|
||||||
def can_add_members?
|
def can_add_members?
|
||||||
members.count < MAX_MEMBERS
|
# Use counter cache if available, otherwise count
|
||||||
|
member_count < MAX_MEMBERS
|
||||||
|
end
|
||||||
|
|
||||||
|
def member_count
|
||||||
|
# Cache the count to avoid repeated queries
|
||||||
|
@member_count ||= members.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def pending_invitations_count
|
||||||
|
@pending_invitations_count ||= family_invitations.active.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def owners
|
||||||
|
# Get family owners efficiently
|
||||||
|
members.joins(:family_membership)
|
||||||
|
.where(family_memberships: { role: :owner })
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner
|
||||||
|
# Get the primary owner (creator) efficiently
|
||||||
|
@owner ||= creator
|
||||||
|
end
|
||||||
|
|
||||||
|
def full?
|
||||||
|
member_count >= MAX_MEMBERS
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_invitations
|
||||||
|
family_invitations.active.includes(:invited_by)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clear cached counters when members change
|
||||||
|
def clear_member_cache!
|
||||||
|
@member_count = nil
|
||||||
|
@pending_invitations_count = nil
|
||||||
|
@owner = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ class FamilyInvitation < ApplicationRecord
|
||||||
|
|
||||||
before_validation :generate_token, :set_expiry, on: :create
|
before_validation :generate_token, :set_expiry, on: :create
|
||||||
|
|
||||||
|
# Clear family cache when invitation status changes
|
||||||
|
after_update :clear_family_cache, if: :saved_change_to_status?
|
||||||
|
after_destroy :clear_family_cache
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at < Time.current
|
expires_at < Time.current
|
||||||
end
|
end
|
||||||
|
|
@ -34,4 +38,8 @@ class FamilyInvitation < ApplicationRecord
|
||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
|
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def clear_family_cache
|
||||||
|
family&.clear_member_cache!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,15 @@ class FamilyMembership < ApplicationRecord
|
||||||
validates :role, presence: true
|
validates :role, presence: true
|
||||||
|
|
||||||
enum :role, { owner: 0, member: 1 }
|
enum :role, { owner: 0, member: 1 }
|
||||||
|
|
||||||
|
# Clear family cache when membership changes
|
||||||
|
after_create :clear_family_cache
|
||||||
|
after_update :clear_family_cache
|
||||||
|
after_destroy :clear_family_cache
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def clear_family_cache
|
||||||
|
family&.clear_member_cache!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,11 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@error_message = 'Failed to join family due to validation errors.'
|
handle_record_invalid_error(e)
|
||||||
|
false
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_generic_error(e)
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -55,7 +58,7 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_family_capacity
|
def validate_family_capacity
|
||||||
return true if invitation.family.members.count < Family::MAX_MEMBERS
|
return true unless invitation.family.full?
|
||||||
|
|
||||||
@error_message = 'This family has reached the maximum number of members.'
|
@error_message = 'This family has reached the maximum number of members.'
|
||||||
false
|
false
|
||||||
|
|
@ -88,12 +91,31 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_owner_notification
|
def send_owner_notification
|
||||||
|
return unless defined?(Notification)
|
||||||
|
|
||||||
Notification.create!(
|
Notification.create!(
|
||||||
user: invitation.family.creator,
|
user: invitation.family.creator,
|
||||||
kind: :info,
|
kind: :info,
|
||||||
title: 'New Family Member',
|
title: 'New Family Member',
|
||||||
content: "#{user.email} has joined your family"
|
content: "#{user.email} has joined your family"
|
||||||
)
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
# Don't fail the entire operation if notification fails
|
||||||
|
Rails.logger.warn "Failed to send family join notification: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_record_invalid_error(error)
|
||||||
|
@error_message = if error.record&.errors&.any?
|
||||||
|
error.record.errors.full_messages.first
|
||||||
|
else
|
||||||
|
"Failed to join family: #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_generic_error(error)
|
||||||
|
Rails.logger.error "Unexpected error in Families::AcceptInvitation: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
|
@error_message = 'An unexpected error occurred while joining the family. Please try again'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,47 +2,72 @@
|
||||||
|
|
||||||
module Families
|
module Families
|
||||||
class Create
|
class Create
|
||||||
attr_reader :user, :name, :family, :errors
|
include ActiveModel::Validations
|
||||||
|
|
||||||
|
attr_reader :user, :name, :family, :error_message
|
||||||
|
|
||||||
|
validates :name, presence: { message: 'Family name is required' }
|
||||||
|
validates :name, length: {
|
||||||
|
maximum: 50,
|
||||||
|
message: 'Family name must be 50 characters or less'
|
||||||
|
}
|
||||||
|
|
||||||
def initialize(user:, name:)
|
def initialize(user:, name:)
|
||||||
@user = user
|
@user = user
|
||||||
@name = name
|
@name = name&.strip
|
||||||
@errors = {}
|
@error_message = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
if user.in_family?
|
return false unless valid?
|
||||||
@errors[:user] = 'User is already in a family'
|
return false unless validate_user_eligibility
|
||||||
return false
|
return false unless validate_feature_access
|
||||||
end
|
|
||||||
|
|
||||||
if user.created_family.present?
|
|
||||||
@errors[:user] = 'User has already created a family'
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
unless can_create_family?
|
|
||||||
@errors[:base] = 'Cannot create family'
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
create_family
|
create_family
|
||||||
create_owner_membership
|
create_owner_membership
|
||||||
|
send_notification
|
||||||
end
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
if @family&.errors&.any?
|
handle_record_invalid_error(e)
|
||||||
@family.errors.each { |attribute, message| @errors[attribute] = message }
|
false
|
||||||
else
|
rescue ActiveRecord::RecordNotUnique => e
|
||||||
@errors[:base] = e.message
|
handle_uniqueness_error(e)
|
||||||
end
|
false
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_generic_error(e)
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def validate_user_eligibility
|
||||||
|
if user.in_family?
|
||||||
|
@error_message = 'You must leave your current family before creating a new one'
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if user.created_family.present?
|
||||||
|
@error_message = 'You have already created a family. Each user can only create one family'
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_feature_access
|
||||||
|
return true if can_create_family?
|
||||||
|
|
||||||
|
@error_message = if DawarichSettings.self_hosted?
|
||||||
|
'Family feature is not available on this instance'
|
||||||
|
else
|
||||||
|
'Family feature requires an active subscription'
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def can_create_family?
|
def can_create_family?
|
||||||
return true if DawarichSettings.self_hosted?
|
return true if DawarichSettings.self_hosted?
|
||||||
|
|
||||||
|
|
@ -52,7 +77,7 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_family
|
def create_family
|
||||||
@family = Family.create!(name:, creator: user)
|
@family = Family.create!(name: name, creator: user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_owner_membership
|
def create_owner_membership
|
||||||
|
|
@ -62,5 +87,37 @@ module Families
|
||||||
role: :owner
|
role: :owner
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_notification
|
||||||
|
return unless defined?(Notification)
|
||||||
|
|
||||||
|
Notification.create!(
|
||||||
|
user: user,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Family Created',
|
||||||
|
content: "You've successfully created the family '#{family.name}'"
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
# Don't fail the entire operation if notification fails
|
||||||
|
Rails.logger.warn "Failed to send family creation notification: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_record_invalid_error(error)
|
||||||
|
if family&.errors&.any?
|
||||||
|
@error_message = family.errors.full_messages.first
|
||||||
|
else
|
||||||
|
@error_message = "Failed to create family: #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_uniqueness_error(_error)
|
||||||
|
@error_message = 'A family with this name already exists for your account'
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_generic_error(error)
|
||||||
|
Rails.logger.error "Unexpected error in Families::Create: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
|
@error_message = 'An unexpected error occurred while creating the family. Please try again'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,19 @@ module Families
|
||||||
|
|
||||||
true
|
true
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
errors.add(:base, e.message)
|
handle_record_invalid_error(e)
|
||||||
|
false
|
||||||
|
rescue Net::SMTPError => e
|
||||||
|
handle_email_error(e)
|
||||||
|
false
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_generic_error(e)
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def error_message
|
def error_message
|
||||||
return errors.full_messages.first if errors.any?
|
return errors.full_messages.first if errors.any?
|
||||||
|
return @custom_error_message if @custom_error_message
|
||||||
|
|
||||||
'Failed to send invitation'
|
'Failed to send invitation'
|
||||||
end
|
end
|
||||||
|
|
@ -43,7 +50,7 @@ module Families
|
||||||
return add_error_and_false(:invited_by,
|
return add_error_and_false(:invited_by,
|
||||||
'You must be a family owner to send invitations')
|
'You must be a family owner to send invitations')
|
||||||
end
|
end
|
||||||
return add_error_and_false(:family, 'Family is full') if family.members.count >= Family::MAX_MEMBERS
|
return add_error_and_false(:family, 'Family is full') if family.full?
|
||||||
return add_error_and_false(:email, 'User is already in a family') if user_already_in_family?
|
return add_error_and_false(:email, 'User is already in a family') if user_already_in_family?
|
||||||
return add_error_and_false(:email, 'Invitation already sent to this email') if pending_invitation_exists?
|
return add_error_and_false(:email, 'Invitation already sent to this email') if pending_invitation_exists?
|
||||||
|
|
||||||
|
|
@ -74,16 +81,48 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_invitation_email
|
def send_invitation_email
|
||||||
FamilyMailer.invitation(@invitation).deliver_later
|
# Send email in background with retry logic
|
||||||
|
FamilyMailer.invitation(@invitation).deliver_later(
|
||||||
|
queue: :mailer,
|
||||||
|
retry: 3,
|
||||||
|
wait: 30.seconds
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_notification
|
def send_notification
|
||||||
|
return unless defined?(Notification)
|
||||||
|
|
||||||
Notification.create!(
|
Notification.create!(
|
||||||
user: invited_by,
|
user: invited_by,
|
||||||
kind: :info,
|
kind: :info,
|
||||||
title: 'Invitation Sent',
|
title: 'Invitation Sent',
|
||||||
content: "Family invitation sent to #{email}"
|
content: "Family invitation sent to #{email}"
|
||||||
)
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
# Don't fail the entire operation if notification fails
|
||||||
|
Rails.logger.warn "Failed to send invitation notification: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_record_invalid_error(error)
|
||||||
|
@custom_error_message = if invitation&.errors&.any?
|
||||||
|
invitation.errors.full_messages.first
|
||||||
|
else
|
||||||
|
"Failed to create invitation: #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_email_error(error)
|
||||||
|
Rails.logger.error "Email delivery failed for family invitation: #{error.message}"
|
||||||
|
@custom_error_message = 'Failed to send invitation email. Please try again later'
|
||||||
|
|
||||||
|
# Clean up the invitation if email fails
|
||||||
|
invitation&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_generic_error(error)
|
||||||
|
Rails.logger.error "Unexpected error in Families::Invite: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
|
@custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,11 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@error_message = 'Failed to leave family due to validation errors.'
|
handle_record_invalid_error(e)
|
||||||
|
false
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_generic_error(e)
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -66,12 +69,31 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_notification
|
def send_notification
|
||||||
|
return unless defined?(Notification)
|
||||||
|
|
||||||
Notification.create!(
|
Notification.create!(
|
||||||
user: user,
|
user: user,
|
||||||
kind: :info,
|
kind: :info,
|
||||||
title: 'Left Family',
|
title: 'Left Family',
|
||||||
content: "You've left the family"
|
content: "You've left the family"
|
||||||
)
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
# Don't fail the entire operation if notification fails
|
||||||
|
Rails.logger.warn "Failed to send family leave notification: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_record_invalid_error(error)
|
||||||
|
@error_message = if error.record&.errors&.any?
|
||||||
|
error.record.errors.full_messages.first
|
||||||
|
else
|
||||||
|
"Failed to leave family: #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_generic_error(error)
|
||||||
|
Rails.logger.error "Unexpected error in Families::Leave: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
|
@error_message = 'An unexpected error occurred while leaving the family. Please try again'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
28
app/views/shared/_flash_messages.html.erb
Normal file
28
app/views/shared/_flash_messages.html.erb
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<% if flash.any? %>
|
||||||
|
<div class="flash-messages fixed top-4 right-4 z-50 space-y-2">
|
||||||
|
<% flash.each do |type, message| %>
|
||||||
|
<% next if message.blank? %>
|
||||||
|
<div class="alert <%= flash_alert_class(type) %> shadow-lg max-w-md"
|
||||||
|
data-controller="flash-message"
|
||||||
|
data-flash-message-type-value="<%= type %>"
|
||||||
|
data-flash-message-auto-dismiss-value="<%= %w[notice success].include?(type) %>">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= flash_icon(type) %>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
<%= message %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-ghost btn-sm btn-circle"
|
||||||
|
data-action="click->flash-message#dismiss"
|
||||||
|
aria-label="Dismiss">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
||||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
|
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
|
||||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||||
<% if user_signed_in? %>
|
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||||
<li>
|
<li>
|
||||||
<% if current_user.in_family? %>
|
<% if current_user.in_family? %>
|
||||||
<%= link_to 'Family', family_path(current_user.family), class: "#{active_class?(families_path)}" %>
|
<%= link_to 'Family', family_path(current_user.family), class: "#{active_class?(families_path)}" %>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
|
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
|
||||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
|
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
|
||||||
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
||||||
<% if user_signed_in? %>
|
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||||
<li>
|
<li>
|
||||||
<% if current_user.in_family? %>
|
<% if current_user.in_family? %>
|
||||||
<%= link_to 'Family', family_path(current_user.family), class: "mx-1 #{active_class?(families_path)}" %>
|
<%= link_to 'Family', family_path(current_user.family), class: "mx-1 #{active_class?(families_path)}" %>
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,30 @@ class DawarichSettings
|
||||||
@store_geodata ||= STORE_GEODATA
|
@store_geodata ||= STORE_GEODATA
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def family_feature_enabled?
|
||||||
|
@family_feature_enabled ||= self_hosted? || family_subscription_active?
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_subscription_active?
|
||||||
|
# Will be implemented when cloud subscriptions are added
|
||||||
|
# For now, return false for cloud instances to keep family feature
|
||||||
|
# available only for self-hosted instances
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_feature_available_for?(user)
|
||||||
|
return true if self_hosted?
|
||||||
|
return false unless user
|
||||||
|
|
||||||
|
# For cloud instances, check if user has family subscription
|
||||||
|
# This will be implemented when subscription system is added
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def features
|
def features
|
||||||
@features ||= {
|
@features ||= {
|
||||||
reverse_geocoding: reverse_geocoding_enabled?
|
reverse_geocoding: reverse_geocoding_enabled?,
|
||||||
|
family: family_feature_enabled?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ Rails.application.routes.draw do
|
||||||
resources :exports, only: %i[index create destroy]
|
resources :exports, only: %i[index create destroy]
|
||||||
resources :trips
|
resources :trips
|
||||||
|
|
||||||
# Family management routes
|
# Family management routes (only if feature is enabled)
|
||||||
|
# if DawarichSettings.family_feature_enabled?
|
||||||
resources :families do
|
resources :families do
|
||||||
member do
|
member do
|
||||||
delete :leave
|
delete :leave
|
||||||
|
|
@ -72,6 +73,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
# Public family invitation acceptance (no auth required)
|
# Public family invitation acceptance (no auth required)
|
||||||
get 'invitations/:id', to: 'family_invitations#show', as: :public_invitation
|
get 'invitations/:id', to: 'family_invitations#show', as: :public_invitation
|
||||||
|
# end
|
||||||
resources :points, only: %i[index] do
|
resources :points, only: %i[index] do
|
||||||
collection do
|
collection do
|
||||||
delete :bulk_destroy
|
delete :bulk_destroy
|
||||||
|
|
|
||||||
37
db/migrate/20250928000001_add_family_performance_indexes.rb
Normal file
37
db/migrate/20250928000001_add_family_performance_indexes.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddFamilyPerformanceIndexes < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
# Index for family invitations queries
|
||||||
|
unless index_exists?(:family_invitations, %i[family_id status expires_at],
|
||||||
|
name: 'index_family_invitations_on_family_status_expires')
|
||||||
|
add_index :family_invitations, %i[family_id status expires_at],
|
||||||
|
name: 'index_family_invitations_on_family_status_expires',
|
||||||
|
algorithm: :concurrently
|
||||||
|
end
|
||||||
|
|
||||||
|
# Index for family membership queries by role
|
||||||
|
unless index_exists?(:family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role')
|
||||||
|
add_index :family_memberships, %i[family_id role],
|
||||||
|
name: 'index_family_memberships_on_family_and_role',
|
||||||
|
algorithm: :concurrently
|
||||||
|
end
|
||||||
|
|
||||||
|
# Index for user email lookups in invitations (skip if exists)
|
||||||
|
unless index_exists?(:family_invitations, :email)
|
||||||
|
add_index :family_invitations, :email,
|
||||||
|
name: 'index_family_invitations_on_email',
|
||||||
|
algorithm: :concurrently
|
||||||
|
end
|
||||||
|
|
||||||
|
# Composite index for active invitations
|
||||||
|
unless index_exists?(:family_invitations, %i[status expires_at],
|
||||||
|
name: 'index_family_invitations_on_status_and_expires_at')
|
||||||
|
add_index :family_invitations, %i[status expires_at],
|
||||||
|
name: 'index_family_invitations_on_status_and_expires_at',
|
||||||
|
algorithm: :concurrently
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/schema.rb
generated
5
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_09_26_220345) do
|
ActiveRecord::Schema[8.0].define(version: 2025_09_28_000001) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
|
|
@ -116,7 +116,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_26_220345) do
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["email"], name: "index_family_invitations_on_email"
|
t.index ["email"], name: "index_family_invitations_on_email"
|
||||||
t.index ["expires_at"], name: "index_family_invitations_on_expires_at"
|
t.index ["expires_at"], name: "index_family_invitations_on_expires_at"
|
||||||
|
t.index ["family_id", "status", "expires_at"], name: "index_family_invitations_on_family_status_expires"
|
||||||
t.index ["family_id"], name: "index_family_invitations_on_family_id"
|
t.index ["family_id"], name: "index_family_invitations_on_family_id"
|
||||||
|
t.index ["status", "expires_at"], name: "index_family_invitations_on_status_and_expires_at"
|
||||||
t.index ["status"], name: "index_family_invitations_on_status"
|
t.index ["status"], name: "index_family_invitations_on_status"
|
||||||
t.index ["token"], name: "index_family_invitations_on_token", unique: true
|
t.index ["token"], name: "index_family_invitations_on_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
@ -127,6 +129,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_26_220345) do
|
||||||
t.integer "role", default: 1, null: false
|
t.integer "role", default: 1, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["family_id", "role"], name: "index_family_memberships_on_family_and_role"
|
||||||
t.index ["family_id", "role"], name: "index_family_memberships_on_family_id_and_role"
|
t.index ["family_id", "role"], name: "index_family_memberships_on_family_id_and_role"
|
||||||
t.index ["family_id"], name: "index_family_memberships_on_family_id"
|
t.index ["family_id"], name: "index_family_memberships_on_family_id"
|
||||||
t.index ["user_id"], name: "index_family_memberships_on_user_id", unique: true
|
t.index ["user_id"], name: "index_family_memberships_on_user_id", unique: true
|
||||||
|
|
|
||||||
385
docs/FAMILY_FEATURES.md
Normal file
385
docs/FAMILY_FEATURES.md
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
# Family Features Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Family Features system allows users to create and manage family groups for shared location tracking and collaboration. This feature is designed with flexibility for both self-hosted and cloud deployments.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Models
|
||||||
|
|
||||||
|
- **Family**: Central entity representing a family group
|
||||||
|
- **FamilyMembership**: Join table linking users to families with roles
|
||||||
|
- **FamilyInvitation**: Manages invitation flow for new family members
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- families table
|
||||||
|
CREATE TABLE families (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
creator_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- family_memberships table
|
||||||
|
CREATE TABLE family_memberships (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
family_id BIGINT NOT NULL REFERENCES families(id),
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
role INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- family_invitations table
|
||||||
|
CREATE TABLE family_invitations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
family_id BIGINT NOT NULL REFERENCES families(id),
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
invited_by_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
The system includes several performance optimizations:
|
||||||
|
|
||||||
|
- **Database Indexes**: Optimized indexes for common queries
|
||||||
|
- **Caching**: Model-level caching for frequently accessed data
|
||||||
|
- **Background Jobs**: Asynchronous email processing
|
||||||
|
- **Query Optimization**: Includes and preloading for N+1 prevention
|
||||||
|
|
||||||
|
## Feature Gating
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Family features can be enabled/disabled through `DawarichSettings`:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Check if family feature is enabled
|
||||||
|
DawarichSettings.family_feature_enabled?
|
||||||
|
|
||||||
|
# Check if feature is available for specific user
|
||||||
|
DawarichSettings.family_feature_available_for?(user)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Types
|
||||||
|
|
||||||
|
- **Self-hosted**: Family features are enabled by default
|
||||||
|
- **Cloud hosted**: Features require subscription validation
|
||||||
|
- **Disabled**: All family routes and UI elements are hidden
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /families # List/redirect to user's family
|
||||||
|
GET /families/:id # Show family details
|
||||||
|
POST /families # Create new family
|
||||||
|
PATCH /families/:id # Update family
|
||||||
|
DELETE /families/:id # Delete family
|
||||||
|
DELETE /families/:id/leave # Leave family
|
||||||
|
|
||||||
|
# Family Invitations
|
||||||
|
GET /families/:family_id/invitations # List invitations
|
||||||
|
POST /families/:family_id/invitations # Send invitation
|
||||||
|
GET /families/:family_id/invitations/:id # Show invitation
|
||||||
|
DELETE /families/:family_id/invitations/:id # Cancel invitation
|
||||||
|
|
||||||
|
# Family Members
|
||||||
|
GET /families/:family_id/members # List members
|
||||||
|
GET /families/:family_id/members/:id # Show member
|
||||||
|
DELETE /families/:family_id/members/:id # Remove member
|
||||||
|
|
||||||
|
# Public Invitation Acceptance
|
||||||
|
GET /family_invitations/:token # Show invitation
|
||||||
|
POST /family_invitations/:token/accept # Accept invitation
|
||||||
|
POST /family_invitations/:token/decline # Decline invitation
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Responses
|
||||||
|
|
||||||
|
All endpoints return consistent JSON responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... },
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
The system uses Pundit policies for authorization:
|
||||||
|
|
||||||
|
- **FamilyPolicy**: Controls family access and modifications
|
||||||
|
- **FamilyInvitationPolicy**: Manages invitation permissions
|
||||||
|
- **FamilyMembershipPolicy**: Controls member management
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
|
||||||
|
- Only family owners can send invitations
|
||||||
|
- Only family owners can remove members
|
||||||
|
- Members can only leave families voluntarily
|
||||||
|
- Invitations expire automatically for security
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- Email addresses in invitations are validated
|
||||||
|
- Invitation tokens are cryptographically secure
|
||||||
|
- User data is protected through proper authorization
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
All family services implement comprehensive error handling:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
class Families::Create
|
||||||
|
include ActiveModel::Validations
|
||||||
|
|
||||||
|
def call
|
||||||
|
return false unless valid?
|
||||||
|
# ... implementation
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
handle_record_invalid_error(e)
|
||||||
|
false
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_generic_error(e)
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_message
|
||||||
|
return errors.full_messages.first if errors.any?
|
||||||
|
return @custom_error_message if @custom_error_message
|
||||||
|
'Operation failed'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Types
|
||||||
|
|
||||||
|
- **Validation Errors**: Invalid input data
|
||||||
|
- **Authorization Errors**: Insufficient permissions
|
||||||
|
- **Business Logic Errors**: Family limits, existing memberships
|
||||||
|
- **System Errors**: Database, email delivery failures
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### Interactive Elements
|
||||||
|
|
||||||
|
- **Family Creation Form**: Real-time validation
|
||||||
|
- **Invitation Management**: Dynamic invite sending
|
||||||
|
- **Member Management**: Role-based controls
|
||||||
|
- **Flash Messages**: Animated feedback system
|
||||||
|
|
||||||
|
### Stimulus Controllers
|
||||||
|
|
||||||
|
JavaScript controllers provide enhanced interactivity:
|
||||||
|
|
||||||
|
- `family_invitation_controller.js`: Invitation form validation
|
||||||
|
- `family_member_controller.js`: Member management actions
|
||||||
|
- `flash_message_controller.js`: Animated notifications
|
||||||
|
|
||||||
|
## Background Jobs
|
||||||
|
|
||||||
|
### Email Processing
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Invitation emails are sent asynchronously
|
||||||
|
FamilyMailer.invitation(@invitation).deliver_later(
|
||||||
|
queue: :mailer,
|
||||||
|
retry: 3,
|
||||||
|
wait: 30.seconds
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Jobs
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Automatic cleanup of expired invitations
|
||||||
|
class FamilyInvitationsCleanupJob < ApplicationJob
|
||||||
|
def perform
|
||||||
|
# Update expired invitations
|
||||||
|
# Remove old expired/cancelled invitations
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Feature toggles
|
||||||
|
FAMILY_FEATURE_ENABLED=true
|
||||||
|
|
||||||
|
# Email configuration for invitations
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_USERNAME=user@example.com
|
||||||
|
SMTP_PASSWORD=secret
|
||||||
|
|
||||||
|
# Background job configuration
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# config/schedule.rb
|
||||||
|
every 1.hour do
|
||||||
|
runner "FamilyInvitationsCleanupJob.perform_later"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
The family features include comprehensive test coverage:
|
||||||
|
|
||||||
|
- **Unit Tests**: Service classes, models, helpers
|
||||||
|
- **Integration Tests**: Controller actions, API endpoints
|
||||||
|
- **System Tests**: End-to-end user workflows
|
||||||
|
- **Job Tests**: Background job processing
|
||||||
|
|
||||||
|
### Test Patterns
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Service testing pattern
|
||||||
|
RSpec.describe Families::Create do
|
||||||
|
describe '#call' do
|
||||||
|
context 'with valid parameters' do
|
||||||
|
it 'creates a family successfully' do
|
||||||
|
# ... test implementation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid parameters' do
|
||||||
|
it 'returns false and sets error message' do
|
||||||
|
# ... test implementation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
Run migrations to set up family tables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rails db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Index Creation
|
||||||
|
|
||||||
|
Performance indexes are created concurrently:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Handled automatically in migration
|
||||||
|
# Uses disable_ddl_transaction! for zero-downtime deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Jobs
|
||||||
|
|
||||||
|
Ensure Sidekiq is running for email processing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bundle exec sidekiq
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
|
||||||
|
Set up periodic cleanup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to crontab or use whenever gem
|
||||||
|
0 * * * * cd /app && bundle exec rails runner "FamilyInvitationsCleanupJob.perform_later"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
Key metrics to monitor:
|
||||||
|
|
||||||
|
- Family creation rate
|
||||||
|
- Invitation acceptance rate
|
||||||
|
- Email delivery success rate
|
||||||
|
- Background job processing time
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Important events are logged:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Rails.logger.info "Family created: #{family.id}"
|
||||||
|
Rails.logger.warn "Failed to send invitation email: #{error.message}"
|
||||||
|
Rails.logger.error "Unexpected error in family service: #{error.message}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Email Delivery Failures**
|
||||||
|
- Check SMTP configuration
|
||||||
|
- Verify email credentials
|
||||||
|
- Monitor Sidekiq queue
|
||||||
|
|
||||||
|
2. **Authorization Errors**
|
||||||
|
- Verify Pundit policies
|
||||||
|
- Check user permissions
|
||||||
|
- Review family membership status
|
||||||
|
|
||||||
|
3. **Performance Issues**
|
||||||
|
- Monitor database indexes
|
||||||
|
- Check query optimization
|
||||||
|
- Review caching implementation
|
||||||
|
|
||||||
|
### Debug Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check family feature status
|
||||||
|
rails console
|
||||||
|
> DawarichSettings.family_feature_enabled?
|
||||||
|
|
||||||
|
# Monitor background jobs
|
||||||
|
bundle exec sidekiq
|
||||||
|
> Sidekiq::Queue.new('mailer').size
|
||||||
|
|
||||||
|
# Check database indexes
|
||||||
|
rails dbconsole
|
||||||
|
> \d family_invitations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
|
||||||
|
- **Family Statistics**: Shared analytics dashboard
|
||||||
|
- **Location Sharing**: Real-time family member locations
|
||||||
|
- **Group Trips**: Collaborative trip planning
|
||||||
|
- **Enhanced Permissions**: Granular access controls
|
||||||
|
|
||||||
|
### Scalability Considerations
|
||||||
|
|
||||||
|
- **Horizontal Scaling**: Stateless service design
|
||||||
|
- **Database Sharding**: Family-based data partitioning
|
||||||
|
- **Caching Strategy**: Redis-based family data caching
|
||||||
|
- **API Rate Limiting**: Per-family API quotas
|
||||||
417
docs/FAMILY_README.md
Normal file
417
docs/FAMILY_README.md
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
# Family Features
|
||||||
|
|
||||||
|
Dawarich includes comprehensive family management features that allow users to create family groups, invite members, and collaborate on location tracking.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### For Self-Hosted Deployments
|
||||||
|
|
||||||
|
Family features are enabled by default for self-hosted installations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Family features are automatically available
|
||||||
|
# No additional configuration required
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Cloud Deployments
|
||||||
|
|
||||||
|
Family features require subscription validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Contact support to enable family features
|
||||||
|
# Subscription-based access control
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features Overview
|
||||||
|
|
||||||
|
### Family Management
|
||||||
|
- Create and name family groups
|
||||||
|
- Invite members via email
|
||||||
|
- Role-based permissions (owner/member)
|
||||||
|
- Member management and removal
|
||||||
|
|
||||||
|
### Invitation System
|
||||||
|
- Secure email-based invitations
|
||||||
|
- Automatic expiration (7 days)
|
||||||
|
- Token-based acceptance flow
|
||||||
|
- Cancellation and resending options
|
||||||
|
|
||||||
|
### Security & Privacy
|
||||||
|
- Authorization via Pundit policies
|
||||||
|
- Encrypted invitation tokens
|
||||||
|
- Email validation and verification
|
||||||
|
- Automatic cleanup of expired data
|
||||||
|
|
||||||
|
### Performance & Scalability
|
||||||
|
- Optimized database indexes
|
||||||
|
- Background job processing
|
||||||
|
- Intelligent caching strategies
|
||||||
|
- Concurrent database operations
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Creating a Family
|
||||||
|
|
||||||
|
1. Navigate to the Families section
|
||||||
|
2. Click "Create Family"
|
||||||
|
3. Enter a family name
|
||||||
|
4. You become the family owner automatically
|
||||||
|
|
||||||
|
### Inviting Members
|
||||||
|
|
||||||
|
1. Go to your family page
|
||||||
|
2. Click "Invite Member"
|
||||||
|
3. Enter the email address
|
||||||
|
4. The invitation is sent automatically
|
||||||
|
5. Member receives email with acceptance link
|
||||||
|
|
||||||
|
### Accepting Invitations
|
||||||
|
|
||||||
|
1. Member receives invitation email
|
||||||
|
2. Clicks the invitation link
|
||||||
|
3. Must be logged in to Dawarich
|
||||||
|
4. Accepts or declines the invitation
|
||||||
|
5. Automatically joins the family if accepted
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### REST Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List families or redirect to user's family
|
||||||
|
GET /families
|
||||||
|
|
||||||
|
# Show family details (requires authorization)
|
||||||
|
GET /families/:id
|
||||||
|
|
||||||
|
# Create new family
|
||||||
|
POST /families
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"family": {
|
||||||
|
"name": "Smith Family"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update family name
|
||||||
|
PATCH /families/:id
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"family": {
|
||||||
|
"name": "Updated Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete family (owner only, requires empty family)
|
||||||
|
DELETE /families/:id
|
||||||
|
|
||||||
|
# Leave family (members only)
|
||||||
|
DELETE /families/:id/leave
|
||||||
|
|
||||||
|
# Send invitation
|
||||||
|
POST /families/:family_id/invitations
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"invitation": {
|
||||||
|
"email": "member@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cancel invitation
|
||||||
|
DELETE /families/:family_id/invitations/:id
|
||||||
|
|
||||||
|
# Accept invitation (public endpoint)
|
||||||
|
POST /family_invitations/:token/accept
|
||||||
|
|
||||||
|
# Decline invitation (public endpoint)
|
||||||
|
POST /family_invitations/:token/decline
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Responses
|
||||||
|
|
||||||
|
All endpoints return JSON responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"family": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Smith Family",
|
||||||
|
"member_count": 3,
|
||||||
|
"creator": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "owner@example.com"
|
||||||
|
},
|
||||||
|
"members": [...],
|
||||||
|
"pending_invitations": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable/disable family features
|
||||||
|
FAMILY_FEATURE_ENABLED=true
|
||||||
|
|
||||||
|
# For cloud deployments - require subscription
|
||||||
|
FAMILY_SUBSCRIPTION_REQUIRED=true
|
||||||
|
|
||||||
|
# Email configuration for invitations
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_USERNAME=noreply@example.com
|
||||||
|
SMTP_PASSWORD=secret_password
|
||||||
|
|
||||||
|
# Background jobs
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Gating
|
||||||
|
|
||||||
|
Family features can be controlled programmatically:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Check if features are enabled
|
||||||
|
DawarichSettings.family_feature_enabled?
|
||||||
|
# => true/false
|
||||||
|
|
||||||
|
# Check if available for specific user (cloud)
|
||||||
|
DawarichSettings.family_feature_available_for?(user)
|
||||||
|
# => true/false based on subscription
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Main family entity
|
||||||
|
CREATE TABLE families (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
creator_id bigint NOT NULL REFERENCES users(id),
|
||||||
|
created_at timestamp NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User-family relationships with roles
|
||||||
|
CREATE TABLE family_memberships (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
family_id bigint NOT NULL REFERENCES families(id),
|
||||||
|
user_id bigint NOT NULL REFERENCES users(id),
|
||||||
|
role integer NOT NULL DEFAULT 0, -- 0: member, 1: owner
|
||||||
|
created_at timestamp NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL,
|
||||||
|
UNIQUE(family_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invitation management
|
||||||
|
CREATE TABLE family_invitations (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
family_id bigint NOT NULL REFERENCES families(id),
|
||||||
|
email varchar(255) NOT NULL,
|
||||||
|
invited_by_id bigint NOT NULL REFERENCES users(id),
|
||||||
|
token varchar(255) NOT NULL UNIQUE,
|
||||||
|
status integer NOT NULL DEFAULT 0, -- 0: pending, 1: accepted, 2: declined, 3: expired, 4: cancelled
|
||||||
|
expires_at timestamp NOT NULL,
|
||||||
|
created_at timestamp NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Indexes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Optimized for common queries
|
||||||
|
CREATE INDEX CONCURRENTLY idx_family_invitations_family_status_expires
|
||||||
|
ON family_invitations (family_id, status, expires_at);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY idx_family_memberships_family_role
|
||||||
|
ON family_memberships (family_id, role);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY idx_family_invitations_email
|
||||||
|
ON family_invitations (email);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY idx_family_invitations_status_expires
|
||||||
|
ON family_invitations (status, expires_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all family-related tests
|
||||||
|
bundle exec rspec spec/models/family_spec.rb
|
||||||
|
bundle exec rspec spec/services/families/
|
||||||
|
bundle exec rspec spec/controllers/families_controller_spec.rb
|
||||||
|
bundle exec rspec spec/requests/families_spec.rb
|
||||||
|
|
||||||
|
# Run specific test categories
|
||||||
|
bundle exec rspec --tag family
|
||||||
|
bundle exec rspec --tag invitation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
The family features include comprehensive test coverage:
|
||||||
|
|
||||||
|
- **Unit Tests**: Models, services, helpers
|
||||||
|
- **Integration Tests**: Controllers, API endpoints
|
||||||
|
- **System Tests**: End-to-end user workflows
|
||||||
|
- **Job Tests**: Background email processing
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Run database migrations
|
||||||
|
RAILS_ENV=production bundle exec rails db:migrate
|
||||||
|
|
||||||
|
# 2. Precompile assets (includes family JS/CSS)
|
||||||
|
RAILS_ENV=production bundle exec rails assets:precompile
|
||||||
|
|
||||||
|
# 3. Start background job workers
|
||||||
|
bundle exec sidekiq -e production -d
|
||||||
|
|
||||||
|
# 4. Verify deployment
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" \
|
||||||
|
https://your-app.com/families
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zero-Downtime Deployment
|
||||||
|
|
||||||
|
The family feature supports zero-downtime deployment:
|
||||||
|
|
||||||
|
- Database indexes created with `CONCURRENTLY`
|
||||||
|
- Backward-compatible migrations
|
||||||
|
- Feature flags for gradual rollout
|
||||||
|
- Background job graceful shutdown
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Key metrics to monitor:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Family creation rate
|
||||||
|
family_creation_rate: rate(families_created_total[5m])
|
||||||
|
|
||||||
|
# Invitation success rate
|
||||||
|
invitation_success_rate:
|
||||||
|
rate(invitations_accepted_total[5m]) /
|
||||||
|
rate(invitations_sent_total[5m])
|
||||||
|
|
||||||
|
# Email delivery rate
|
||||||
|
email_delivery_success_rate:
|
||||||
|
rate(family_emails_delivered_total[5m]) /
|
||||||
|
rate(family_emails_sent_total[5m])
|
||||||
|
|
||||||
|
# API response times
|
||||||
|
family_api_p95_response_time:
|
||||||
|
histogram_quantile(0.95, family_api_duration_seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Authorization Model
|
||||||
|
|
||||||
|
Family features use Pundit policies for authorization:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Family access control
|
||||||
|
class FamilyPolicy < ApplicationPolicy
|
||||||
|
def show?
|
||||||
|
user_is_member?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
user_is_owner?
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
user_is_owner? && family.members.count <= 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- All invitation tokens are cryptographically secure
|
||||||
|
- Email addresses are validated before storage
|
||||||
|
- Automatic cleanup of expired invitations
|
||||||
|
- User data protected through proper authorization
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
|
||||||
|
- Never log invitation tokens
|
||||||
|
- Validate all email addresses
|
||||||
|
- Use HTTPS for all invitation links
|
||||||
|
- Implement rate limiting on invitation sending
|
||||||
|
- Monitor for suspicious activity patterns
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**1. Email Delivery Failures**
|
||||||
|
```bash
|
||||||
|
# Check SMTP configuration
|
||||||
|
RAILS_ENV=production bundle exec rails console
|
||||||
|
> ActionMailer::Base.smtp_settings
|
||||||
|
|
||||||
|
# Monitor Sidekiq queue
|
||||||
|
bundle exec sidekiq -e production
|
||||||
|
> Sidekiq::Queue.new('mailer').size
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Authorization Errors**
|
||||||
|
```bash
|
||||||
|
# Verify user permissions
|
||||||
|
RAILS_ENV=production bundle exec rails console
|
||||||
|
> user = User.find(1)
|
||||||
|
> family = Family.find(1)
|
||||||
|
> FamilyPolicy.new(user, family).show?
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Performance Issues**
|
||||||
|
```sql
|
||||||
|
-- Check index usage
|
||||||
|
SELECT schemaname, tablename, indexname, idx_scan
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE tablename LIKE 'family%'
|
||||||
|
ORDER BY idx_scan DESC;
|
||||||
|
|
||||||
|
-- Monitor slow queries
|
||||||
|
SELECT query, mean_time, calls
|
||||||
|
FROM pg_stat_statements
|
||||||
|
WHERE query LIKE '%family%'
|
||||||
|
ORDER BY mean_time DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [Family Features Guide](FAMILY_FEATURES.md)
|
||||||
|
- [Deployment Guide](FAMILY_DEPLOYMENT.md)
|
||||||
|
- [API Documentation](/api-docs)
|
||||||
|
|
||||||
|
### Community
|
||||||
|
- [GitHub Issues](https://github.com/Freika/dawarich/issues)
|
||||||
|
- [Discord Server](https://discord.gg/pHsBjpt5J8)
|
||||||
|
- [GitHub Discussions](https://github.com/Freika/dawarich/discussions)
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
|
||||||
|
Contributions to family features are welcome:
|
||||||
|
|
||||||
|
1. Check existing issues for family-related bugs
|
||||||
|
2. Follow the existing code patterns and conventions
|
||||||
|
3. Include comprehensive tests for new features
|
||||||
|
4. Update documentation for any API changes
|
||||||
|
5. Follow the contribution guidelines in CONTRIBUTING.md
|
||||||
|
|
@ -1,19 +1 @@
|
||||||
{
|
{"name":"Dawarich","short_name":"Dawarich","icons":[{"src":"/assets/favicon/android-chrome-192x192-f9610e2af28e4e48ff0472572c0cb9e3902d29bccc2b07f8f03aabf684822355.png","sizes":"192x192","type":"image/png"},{"src":"/assets/favicon/android-chrome-512x512-c2ec8132d773ae99f53955360cdd5691bb38e0ed141bddebd39d896b78b5afb6.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
"name": "Dawarich",
|
|
||||||
"short_name": "Dawarich",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/assets/favicon/android-chrome-192x192-f9610e2af28e4e48ff0472572c0cb9e3902d29bccc2b07f8f03aabf684822355.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/assets/favicon/android-chrome-512x512-c2ec8132d773ae99f53955360cdd5691bb38e0ed141bddebd39d896b78b5afb6.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
||||||
|
|
@ -43,7 +43,7 @@ RSpec.describe Families::Create do
|
||||||
|
|
||||||
it 'sets appropriate error message' do
|
it 'sets appropriate error message' do
|
||||||
service.call
|
service.call
|
||||||
expect(service.errors[:user]).to eq('User is already in a family')
|
expect(service.error_message).to eq('You must leave your current family before creating a new one')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ RSpec.describe Families::Create do
|
||||||
|
|
||||||
it 'sets appropriate error message' do
|
it 'sets appropriate error message' do
|
||||||
service.call
|
service.call
|
||||||
expect(service.errors[:user]).to eq('User has already created a family')
|
expect(service.error_message).to eq('You have already created a family. Each user can only create one family')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue