mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -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_PORT=5432
|
||||
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]
|
||||
|
||||
- **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
|
||||
|
||||
- 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: '✅';
|
||||
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
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family, only: %i[show edit update destroy leave]
|
||||
|
||||
def index
|
||||
|
|
@ -11,8 +12,13 @@ class FamiliesController < ApplicationController
|
|||
def show
|
||||
authorize @family
|
||||
|
||||
@members = @family.members.includes(:family_membership)
|
||||
@pending_invitations = @family.family_invitations.active
|
||||
# Use optimized family methods for better performance
|
||||
@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
|
||||
|
||||
def new
|
||||
|
|
@ -32,9 +38,19 @@ class FamiliesController < ApplicationController
|
|||
else
|
||||
@family = Family.new(family_params)
|
||||
|
||||
# Handle validation errors
|
||||
if service.errors.any?
|
||||
service.errors.each do |attribute, message|
|
||||
@family.errors.add(attribute, message)
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -82,6 +98,12 @@ class FamiliesController < ApplicationController
|
|||
|
||||
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
|
||||
@family = current_user.family
|
||||
redirect_to families_path unless @family
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class FamilyInvitationsController < ApplicationController
|
||||
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_invitation, only: %i[show accept destroy]
|
||||
|
||||
|
|
@ -43,27 +44,62 @@ class FamilyInvitationsController < ApplicationController
|
|||
def accept
|
||||
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(
|
||||
invitation: @invitation,
|
||||
user: current_user
|
||||
)
|
||||
|
||||
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
|
||||
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
||||
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
|
||||
|
||||
def destroy
|
||||
authorize @family, :manage_invitations?
|
||||
|
||||
@invitation.update!(status: :cancelled)
|
||||
redirect_to family_path(@family), notice: 'Invitation cancelled'
|
||||
if @invitation.update(status: :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
|
||||
|
||||
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
|
||||
@family = current_user.family
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class FamilyMembershipsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family
|
||||
before_action :set_membership, only: %i[show destroy]
|
||||
|
||||
|
|
@ -30,6 +31,12 @@ class FamilyMembershipsController < ApplicationController
|
|||
|
||||
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
|
||||
@family = current_user.family
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,42 @@ module ApplicationHelper
|
|||
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)
|
||||
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')
|
||||
|
|
|
|||
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
|
||||
|
||||
# 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?
|
||||
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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ class FamilyInvitation < ApplicationRecord
|
|||
|
||||
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?
|
||||
expires_at < Time.current
|
||||
end
|
||||
|
|
@ -34,4 +38,8 @@ class FamilyInvitation < ApplicationRecord
|
|||
def set_expiry
|
||||
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
|
||||
end
|
||||
|
||||
def clear_family_cache
|
||||
family&.clear_member_cache!
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,4 +8,15 @@ class FamilyMembership < ApplicationRecord
|
|||
validates :role, presence: true
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ module Families
|
|||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
@error_message = 'Failed to join family due to validation errors.'
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
handle_record_invalid_error(e)
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
false
|
||||
end
|
||||
|
||||
|
|
@ -55,7 +58,7 @@ module Families
|
|||
end
|
||||
|
||||
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.'
|
||||
false
|
||||
|
|
@ -88,12 +91,31 @@ module Families
|
|||
end
|
||||
|
||||
def send_owner_notification
|
||||
return unless defined?(Notification)
|
||||
|
||||
Notification.create!(
|
||||
user: invitation.family.creator,
|
||||
kind: :info,
|
||||
title: 'New Family Member',
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,47 +2,72 @@
|
|||
|
||||
module Families
|
||||
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:)
|
||||
@user = user
|
||||
@name = name
|
||||
@errors = {}
|
||||
@name = name&.strip
|
||||
@error_message = nil
|
||||
end
|
||||
|
||||
def call
|
||||
if user.in_family?
|
||||
@errors[:user] = 'User is already in a family'
|
||||
return false
|
||||
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
|
||||
return false unless valid?
|
||||
return false unless validate_user_eligibility
|
||||
return false unless validate_feature_access
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
create_family
|
||||
create_owner_membership
|
||||
send_notification
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
if @family&.errors&.any?
|
||||
@family.errors.each { |attribute, message| @errors[attribute] = message }
|
||||
else
|
||||
@errors[:base] = e.message
|
||||
end
|
||||
handle_record_invalid_error(e)
|
||||
false
|
||||
rescue ActiveRecord::RecordNotUnique => e
|
||||
handle_uniqueness_error(e)
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
false
|
||||
end
|
||||
|
||||
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?
|
||||
return true if DawarichSettings.self_hosted?
|
||||
|
||||
|
|
@ -52,7 +77,7 @@ module Families
|
|||
end
|
||||
|
||||
def create_family
|
||||
@family = Family.create!(name:, creator: user)
|
||||
@family = Family.create!(name: name, creator: user)
|
||||
end
|
||||
|
||||
def create_owner_membership
|
||||
|
|
@ -62,5 +87,37 @@ module Families
|
|||
role: :owner
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -26,12 +26,19 @@ module Families
|
|||
|
||||
true
|
||||
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
|
||||
end
|
||||
|
||||
def error_message
|
||||
return errors.full_messages.first if errors.any?
|
||||
return @custom_error_message if @custom_error_message
|
||||
|
||||
'Failed to send invitation'
|
||||
end
|
||||
|
|
@ -43,7 +50,7 @@ module Families
|
|||
return add_error_and_false(:invited_by,
|
||||
'You must be a family owner to send invitations')
|
||||
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, 'Invitation already sent to this email') if pending_invitation_exists?
|
||||
|
||||
|
|
@ -74,16 +81,48 @@ module Families
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def send_notification
|
||||
return unless defined?(Notification)
|
||||
|
||||
Notification.create!(
|
||||
user: invited_by,
|
||||
kind: :info,
|
||||
title: 'Invitation Sent',
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,8 +19,11 @@ module Families
|
|||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
@error_message = 'Failed to leave family due to validation errors.'
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
handle_record_invalid_error(e)
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
false
|
||||
end
|
||||
|
||||
|
|
@ -66,12 +69,31 @@ module Families
|
|||
end
|
||||
|
||||
def send_notification
|
||||
return unless defined?(Notification)
|
||||
|
||||
Notification.create!(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Left 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
|
||||
|
|
|
|||
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 '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>
|
||||
<% if user_signed_in? %>
|
||||
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||
<li>
|
||||
<% if current_user.in_family? %>
|
||||
<%= 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 '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>
|
||||
<% if user_signed_in? %>
|
||||
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||
<li>
|
||||
<% if current_user.in_family? %>
|
||||
<%= 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
|
||||
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
|
||||
@features ||= {
|
||||
reverse_geocoding: reverse_geocoding_enabled?
|
||||
reverse_geocoding: reverse_geocoding_enabled?,
|
||||
family: family_feature_enabled?
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ Rails.application.routes.draw do
|
|||
resources :exports, only: %i[index create destroy]
|
||||
resources :trips
|
||||
|
||||
# Family management routes
|
||||
# Family management routes (only if feature is enabled)
|
||||
# if DawarichSettings.family_feature_enabled?
|
||||
resources :families do
|
||||
member do
|
||||
delete :leave
|
||||
|
|
@ -72,6 +73,7 @@ Rails.application.routes.draw do
|
|||
|
||||
# Public family invitation acceptance (no auth required)
|
||||
get 'invitations/:id', to: 'family_invitations#show', as: :public_invitation
|
||||
# end
|
||||
resources :points, only: %i[index] do
|
||||
collection do
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
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.index ["email"], name: "index_family_invitations_on_email"
|
||||
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 ["status", "expires_at"], name: "index_family_invitations_on_status_and_expires_at"
|
||||
t.index ["status"], name: "index_family_invitations_on_status"
|
||||
t.index ["token"], name: "index_family_invitations_on_token", unique: true
|
||||
end
|
||||
|
|
@ -127,6 +129,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_26_220345) do
|
|||
t.integer "role", default: 1, null: false
|
||||
t.datetime "created_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"], name: "index_family_memberships_on_family_id"
|
||||
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
|
||||
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
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ RSpec.describe Families::Create do
|
|||
|
||||
it 'sets appropriate error message' do
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue