Complete phase 5

This commit is contained in:
Eugene Burmakin 2025-09-28 13:10:07 +02:00
parent e17f732706
commit 1f67e889e3
30 changed files with 1618 additions and 71 deletions

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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')

View 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

View 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)
}
}

View 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...
`
}
}

View 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)
}
}
}

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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 %>

View file

@ -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)}" %>

View file

@ -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

View file

@ -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

View 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
View file

@ -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
View 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
View 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

View file

@ -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"}

View file

@ -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