Merge pull request #1803 from Freika/feature/family

Family
This commit is contained in:
Evgenii Burmakin 2025-10-13 15:08:06 +02:00 committed by GitHub
commit ea340df343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 6642 additions and 106 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

@ -74,18 +74,6 @@ jobs:
# Set platforms based on version type and release type
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
# Check if this is a patch version (x.y.z where z > 0)
if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then
echo "Detected patch version ($VERSION) - building for AMD64 only"
PLATFORMS="linux/amd64"
elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
echo "Detected minor version ($VERSION) - building for all platforms"
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
else
echo "Version format not recognized or non-semver - using AMD64 only for safety"
PLATFORMS="linux/amd64"
fi
# Add :rc tag for pre-releases
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
TAGS="${TAGS},freikin/dawarich:rc"

32
AGENTS.md Normal file
View file

@ -0,0 +1,32 @@
# Repository Guidelines
## Project Structure & Module Organization
- `app/` holds the Rails application: controllers and views under feature-oriented folders, `services/` for importers and background workflows, and `policies/` for Pundit authorization.
- `app/javascript/` contains Stimulus controllers (`controllers/`), map widgets (`maps/`), and Tailwind/Turbo setup in `application.js`.
- `lib/` stores reusable support code and rake tasks, while `config/` tracks environment settings, credentials, and initializers.
- `db/` carries schema migrations and data migrations; `spec/` provides RSpec coverage; `e2e/` hosts Playwright scenarios; `docker/` bundles deployment compose files.
## Build, Test, and Development Commands
- `bundle exec rails db:prepare` initializes or migrates the PostgreSQL database.
- `bundle exec bin/dev` starts the Rails app plus JS bundler via Foreman using `Procfile.dev` (set `PROMETHEUS_EXPORTER_ENABLED=true` to use the Prometheus profile).
- `bundle exec sidekiq` runs background jobs locally alongside the web server.
- `docker compose -f docker/docker-compose.yml up` brings up the containerized stack for end-to-end smoke checks.
## Coding Style & Naming Conventions
- Follow default Ruby style with two-space indentation and snake_case filenames; run `bin/rubocop` before pushing.
- JavaScript modules in `app/javascript/` use ES modules and Stimulus naming (`*_controller.js`); keep exports camelCase and limit files to a single controller.
- Tailwind classes power the UI; co-locate shared styles under `app/javascript/styles/` rather than inline overrides.
## Testing Guidelines
- Use `bundle exec rspec` for unit and feature specs; mirror production behavior by tagging jobs or services with factories in `spec/support`.
- End-to-end flows live in `e2e/`; execute `npx playwright test` (set `BASE_URL` if the server runs on a non-default port).
- Commit failing scenarios together with the fix, and prefer descriptive `it "..."` strings that capture user intent.
## Commit & Pull Request Guidelines
- Write concise, imperative commit titles (e.g., `Add family sharing policy`); group related changes rather than omnibus commits.
- Target pull requests at the `dev` branch, describe the motivation, reference GitHub issues when applicable, and attach screenshots for UI-facing changes.
- Confirm CI, lint, and test status before requesting review; call out migrations or data tasks in the PR checklist.
## Environment & Configuration Tips
- Copy `.env.example` to `.env` or rely on Docker secrets to supply API keys, map tokens, and mail credentials.
- Regenerate credentials with `bin/rails credentials:edit` when altering secrets, and avoid committing any generated `.env` or `credentials.yml.enc` changes.

View file

@ -4,7 +4,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.33.1]
# [0.34.0] - 2025-10-10
## The Family release
In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time.
## Added
- Users can now create family groups and invite members to join.
## Fixed
- Sign out button works again. #1844
## Changed
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
# [0.33.1] - 2025-10-07
## Changed

View file

@ -1,5 +1,7 @@
## How to contribute to Dawarich
Refer to [Repository Guidelines](AGENTS.md) for structure, tooling, and workflow expectations before submitting changes.
#### **Did you find a bug?**
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Freika/dawarich/issues).

View file

@ -1,2 +1,3 @@
release: bundle exec rails db:migrate
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml

View file

@ -5,11 +5,6 @@
{ "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" },
{ "url": "https://github.com/heroku/heroku-buildpack-ruby.git" }
],
"scripts": {
"dokku": {
"predeploy": "bundle exec rails db:migrate"
}
},
"healthchecks": {
"web": [
{

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

@ -139,3 +139,51 @@
border-radius: 3px !important;
padding: 2px !important;
}
/* Family member tooltip - dark styled like the visit popup */
.leaflet-tooltip.family-member-tooltip {
background-color: #374151 !important;
color: #ffffff !important;
border: 1px solid #4b5563 !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-size: 11px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
}
.leaflet-tooltip.family-member-tooltip::before {
border-top-color: #374151 !important;
}
/* Family member popup - just override colors, keep default layout */
.leaflet-popup-content-wrapper:has(.family-member-popup) {
background-color: #1f2937 !important;
color: #f9fafb !important;
}
.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip {
background-color: #1f2937 !important;
}
/* Family member marker pulse animation for recent updates */
@keyframes family-marker-pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.family-member-marker-recent {
animation: family-marker-pulse 2s infinite;
border-radius: 50% !important;
}
.family-member-marker-recent .leaflet-marker-icon > div {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
border-radius: 50%;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chart-column-icon lucide-chart-column"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 275 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-alert-icon lucide-circle-alert"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>

After

Width:  |  Height:  |  Size: 360 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heart-icon lucide-heart"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/></svg>

After

Width:  |  Height:  |  Size: 395 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-check-icon lucide-shield-check"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 447 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>

After

Width:  |  Height:  |  Size: 445 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>

After

Width:  |  Height:  |  Size: 398 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-icon lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>

After

Width:  |  Height:  |  Size: 315 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users-icon lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class FamilyLocationsChannel < ApplicationCable::Channel
def subscribed
return reject unless family_feature_enabled?
return reject unless current_user.in_family?
stream_for current_user.family
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
private
def family_feature_enabled?
DawarichSettings.family_feature_enabled?
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Api::V1::FamiliesController < ApiController
before_action :ensure_family_feature_enabled!
before_action :ensure_user_in_family!
def locations
family_locations = Families::Locations.new(current_api_user).call
render json: {
locations: family_locations,
updated_at: Time.current.iso8601,
sharing_enabled: current_api_user.family_sharing_enabled?
}
end
private
def ensure_user_in_family!
return if current_api_user.in_family?
render json: { error: 'User is not part of a family' }, status: :forbidden
end
end

View file

@ -56,6 +56,12 @@ class ApplicationController < ActionController::Base
end
end
def ensure_family_feature_enabled!
return if DawarichSettings.family_feature_enabled?
render json: { error: 'Family feature is not enabled' }, status: :forbidden
end
private
def set_self_hosted_status
@ -69,8 +75,8 @@ class ApplicationController < ActionController::Base
end
def user_not_authorized
redirect_back fallback_location: root_path,
alert: 'You are not authorized to perform this action.',
status: :see_other
redirect_to (request.referer || root_path),
alert: 'You are not authorized to perform this action.',
status: :see_other
end
end

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true
class FamiliesController < ApplicationController
before_action :authenticate_user!
before_action :ensure_family_feature_enabled!
before_action :set_family, only: %i[show edit update destroy update_location_sharing]
def show
authorize @family
@members = @family.members.includes(:family_membership).order(:email)
@pending_invitations = @family.active_invitations.order(:created_at)
@member_count = @family.member_count
@can_invite = @family.can_add_members?
end
def new
redirect_to family_path and return if current_user.in_family?
@family = Family.new
authorize @family
end
def create
@family = Family.new(family_params)
authorize @family
service = Families::Create.new(
user: current_user,
name: family_params[:name]
)
if service.call
redirect_to family_path, notice: 'Family created successfully!'
else
@family = Family.new(family_params)
if service.errors.any?
service.errors.each do |error|
@family.errors.add(error.attribute, error.message)
end
end
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
def edit
authorize @family
end
def update
authorize @family
if @family.update(family_params)
redirect_to family_path, notice: 'Family updated successfully!'
else
render :edit, status: :unprocessable_content
end
end
def destroy
authorize @family
if @family.members.count > 1
redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.'
else
@family.destroy
redirect_to new_family_path, notice: 'Family deleted successfully!'
end
end
def update_location_sharing
result = Families::UpdateLocationSharing.new(
user: current_user,
enabled: params[:enabled],
duration: params[:duration]
).call
render json: result.payload, status: result.status
end
private
def set_family
@family = current_user.family
redirect_to new_family_path, alert: 'You are not in a family' unless @family
end
def family_params
params.require(:family).permit(:name)
end
end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
class Family::InvitationsController < ApplicationController
before_action :authenticate_user!, except: %i[show]
before_action :ensure_family_feature_enabled!, except: %i[show]
before_action :set_family, except: %i[show]
before_action :set_invitation_by_id_and_family, only: %i[destroy]
def index
authorize @family, :show?
@pending_invitations = @family.family_invitations.active
end
def show
@invitation = Family::Invitation.find_by!(token: params[:token])
if @invitation.expired?
redirect_to root_path, alert: 'This invitation has expired.' and return
end
unless @invitation.pending?
redirect_to root_path, alert: 'This invitation is no longer valid.' and return
end
end
def create
authorize @family, :invite?
service = Families::Invite.new(
family: @family,
email: invitation_params[:email],
invited_by: current_user
)
if service.call
redirect_to family_path, notice: 'Invitation sent successfully!'
else
redirect_to family_path, alert: service.error_message || 'Failed to send invitation'
end
end
def destroy
authorize @family, :manage_invitations?
begin
if @invitation.update(status: :cancelled)
redirect_to family_path, notice: 'Invitation cancelled'
else
redirect_to family_path, 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, alert: 'An unexpected error occurred while cancelling the invitation'
end
end
private
def set_family
@family = current_user.family
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
end
def set_invitation_by_id_and_family
# For authenticated nested routes: /families/:family_id/invitations/:id
# The :id param contains the token value
@family = current_user.family
@invitation = @family.family_invitations.find_by!(token: params[:id])
end
def invitation_params
params.require(:family_invitation).permit(:email)
end
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
class Family::MembershipsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_family_feature_enabled!
before_action :set_family, except: %i[create]
before_action :set_membership, only: %i[destroy]
before_action :set_invitation, only: %i[create]
def create
authorize @invitation, policy_class: Family::MembershipPolicy
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: current_user
)
if service.call
redirect_to family_path, notice: 'Welcome to the family!'
else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
end
rescue Pundit::NotAuthorizedError
if @invitation.expired?
redirect_to root_path, alert: 'This invitation is no longer valid or has expired'
elsif !@invitation.pending?
redirect_to root_path, alert: 'This invitation has already been processed'
elsif @invitation.email != current_user.email
redirect_to root_path, alert: 'This invitation is not for your email address'
else
redirect_to root_path, alert: 'You are not authorized to accept this 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 @membership
member_user = @membership.user
service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user)
if service.call
if member_user == current_user
redirect_to new_family_path, notice: 'You have left the family'
else
redirect_to family_path, notice: "#{member_user.email} has been removed from the family"
end
else
redirect_to family_path, alert: service.error_message || 'Failed to remove member'
end
end
private
def set_family
@family = current_user.family
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
end
def set_membership
@membership = @family.family_memberships.find(params[:id])
end
def set_invitation
@invitation = Family::Invitation.find_by!(token: params[:token])
end
end

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
class Users::RegistrationsController < Devise::RegistrationsController
before_action :set_invitation, only: %i[new create]
before_action :check_registration_allowed, only: %i[new create]
def new
build_resource({})
resource.email = @invitation.email if @invitation
yield resource if block_given?
respond_with resource
end
def create
super do |resource|
if resource.persisted? && @invitation
accept_invitation_for_user(resource)
end
end
end
protected
def after_sign_up_path_for(resource)
return family_path if @invitation&.family
super(resource)
end
def after_inactive_sign_up_path_for(resource)
return family_path if @invitation&.family
super(resource)
end
private
def check_registration_allowed
return unless self_hosted_mode?
return if valid_invitation_token?
redirect_to root_path,
alert: 'Registration is not available. Please contact your administrator for access.'
end
def set_invitation
return unless invitation_token.present?
@invitation = Family::Invitation.find_by(token: invitation_token)
end
def self_hosted_mode?
env_value = ENV['SELF_HOSTED']
return ActiveModel::Type::Boolean.new.cast(env_value) unless env_value.nil?
false
end
def valid_invitation_token?
@invitation&.can_be_accepted?
end
def invitation_token
@invitation_token ||= params[:invitation_token] ||
params.dig(:user, :invitation_token) ||
session[:invitation_token]
end
def accept_invitation_for_user(user)
return unless @invitation&.can_be_accepted?
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: user
)
if service.call
flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
else
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
end
rescue StandardError => e
Rails.logger.error "Error accepting invitation during registration: #{e.message}"
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again."
end
def sign_up_params
super
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
class Users::SessionsController < Devise::SessionsController
before_action :load_invitation_context, only: [:new]
def new
super
end
protected
def after_sign_in_path_for(resource)
if invitation_token.present?
invitation = Family::Invitation.find_by(token: invitation_token)
if invitation&.can_be_accepted?
return family_invitation_path(invitation.token)
end
end
super(resource)
end
private
def load_invitation_context
return unless invitation_token.present?
@invitation = Family::Invitation.find_by(token: invitation_token)
end
def invitation_token
@invitation_token ||= params[:invitation_token] || session[:invitation_token]
end
end

View file

@ -1,12 +1,23 @@
# frozen_string_literal: true
module ApplicationHelper
def classes_for_flash(flash_type)
case flash_type.to_sym
when :error
'bg-red-100 text-red-700 border-red-300'
def flash_alert_class(type)
case type.to_sym
when :notice, :success then 'alert-success'
when :alert, :error then 'alert-error'
when :warning then 'alert-warning'
when :info then 'alert-info'
else 'alert-info'
end
end
def flash_icon(type)
case type.to_sym
when :notice, :success then icon 'circle-check'
when :alert, :error then icon 'circle-x'
when :warning then icon 'circle-alert'
else
'bg-blue-100 text-blue-700 border-blue-300'
icon 'info'
end
end

View file

@ -1,5 +1,6 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@rails/ujs"
import "@rails/actioncable"
import "controllers"
import "@hotwired/turbo-rails"
@ -12,3 +13,5 @@ import "./channels"
import "trix"
import "@rails/actiontext"
Rails.start()

View file

@ -0,0 +1,24 @@
import consumer from "./consumer"
// Only create subscription if family feature is enabled
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]');
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {};
if (features.family) {
consumer.subscriptions.create("FamilyLocationsChannel", {
connected() {
// Connected to family locations channel
},
disconnected() {
// Disconnected from family locations channel
},
received(data) {
// Pass data to family members controller if it exists
if (window.familyMembersController) {
window.familyMembersController.updateSingleMemberLocation(data);
}
}
});
}

View file

@ -2,3 +2,4 @@
import "notifications_channel"
import "points_channel"
import "imports_channel"
import "family_locations_channel"

View file

@ -0,0 +1,486 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import { showFlashMessage } from "../maps/helpers";
export default class extends Controller {
static targets = [];
static values = {
features: Object,
userTheme: String
}
connect() {
console.log("Family members controller connected");
// Wait for maps controller to be ready
this.waitForMap();
}
disconnect() {
this.cleanup();
console.log("Family members controller disconnected");
}
waitForMap() {
// Find the maps controller element
const mapElement = document.querySelector('[data-controller*="maps"]');
if (!mapElement) {
console.warn('Maps controller element not found');
return;
}
// Wait for the maps controller to be initialized
const checkMapReady = () => {
if (window.mapsController && window.mapsController.map) {
this.initializeFamilyFeatures();
} else {
setTimeout(checkMapReady, 100);
}
};
checkMapReady();
}
initializeFamilyFeatures() {
this.map = window.mapsController.map;
if (!this.map) {
console.warn('Map not available for family members controller');
return;
}
// Initialize family member markers layer
this.familyMarkersLayer = L.layerGroup();
this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates
this.familyMarkers = {}; // Store marker references by user_id
// Expose controller globally for ActionCable channel
window.familyMembersController = this;
// Add to layer control immediately (layer will be empty until data is fetched)
this.addToLayerControl();
// Listen for family data updates
this.setupEventListeners();
}
createFamilyMarkers() {
// Clear existing family markers
if (this.familyMarkersLayer) {
this.familyMarkersLayer.clearLayers();
}
// Clear marker references
this.familyMarkers = {};
// Only proceed if family feature is enabled and we have family member locations
if (!this.featuresValue.family ||
!this.familyMemberLocations ||
Object.keys(this.familyMemberLocations).length === 0) {
return;
}
const bounds = [];
Object.values(this.familyMemberLocations).forEach((location) => {
if (!location || !location.latitude || !location.longitude) {
return;
}
// Get the first letter of the email or use '?' as fallback
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
// Check if this is a recent update (within last 5 minutes)
const isRecent = this.isRecentUpdate(location.updated_at);
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
// Create a distinct marker for family members with email initial
const familyMarker = L.marker([location.latitude, location.longitude], {
icon: L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: markerClass
})
});
// Format timestamp for display
const lastSeen = new Date(location.updated_at).toLocaleString();
// Create small tooltip that shows automatically
const tooltipContent = this.createTooltipContent(lastSeen);
const tooltip = familyMarker.bindTooltip(tooltipContent, {
permanent: true,
direction: 'top',
offset: [0, -12],
className: 'family-member-tooltip'
});
// Create detailed popup that shows on click
const popupContent = this.createPopupContent(location, lastSeen);
familyMarker.bindPopup(popupContent);
// Hide tooltip when popup opens, show when popup closes
familyMarker.on('popupopen', () => {
familyMarker.closeTooltip();
});
familyMarker.on('popupclose', () => {
familyMarker.openTooltip();
});
this.familyMarkersLayer.addLayer(familyMarker);
// Store marker reference by user_id for efficient updates
this.familyMarkers[location.user_id] = familyMarker;
// Add to bounds array for auto-zoom
bounds.push([location.latitude, location.longitude]);
});
// Store bounds for later use
this.familyMemberBounds = bounds;
}
// Update a single family member's location in real-time
updateSingleMemberLocation(locationData) {
if (!this.featuresValue.family) return;
if (!locationData || !locationData.user_id) return;
// Update stored location data
this.familyMemberLocations[locationData.user_id] = locationData;
// If the Family Members layer is not currently visible, just store the data
if (!this.map.hasLayer(this.familyMarkersLayer)) {
return;
}
// Get existing marker for this user
const existingMarker = this.familyMarkers[locationData.user_id];
if (existingMarker) {
// Update existing marker position and content
existingMarker.setLatLng([locationData.latitude, locationData.longitude]);
// Update marker icon with pulse animation for recent updates
const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?';
const isRecent = this.isRecentUpdate(locationData.updated_at);
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
const newIcon = L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: markerClass
});
existingMarker.setIcon(newIcon);
// Update tooltip content
const lastSeen = new Date(locationData.updated_at).toLocaleString();
const tooltipContent = this.createTooltipContent(lastSeen);
existingMarker.setTooltipContent(tooltipContent);
// Update popup content
const popupContent = this.createPopupContent(locationData, lastSeen);
existingMarker.setPopupContent(popupContent);
} else {
// Create new marker for this user
this.createSingleFamilyMarker(locationData);
}
}
// Check if location was updated within the last 5 minutes
isRecentUpdate(updatedAt) {
const updateTime = new Date(updatedAt);
const now = new Date();
const diffMinutes = (now - updateTime) / 1000 / 60;
return diffMinutes < 5;
}
// Create a marker for a single family member
createSingleFamilyMarker(location) {
if (!location || !location.latitude || !location.longitude) return;
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
const isRecent = this.isRecentUpdate(location.updated_at);
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
const familyMarker = L.marker([location.latitude, location.longitude], {
icon: L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: markerClass
})
});
const lastSeen = new Date(location.updated_at).toLocaleString();
const tooltipContent = this.createTooltipContent(lastSeen);
familyMarker.bindTooltip(tooltipContent, {
permanent: true,
direction: 'top',
offset: [0, -12],
className: 'family-member-tooltip'
});
const popupContent = this.createPopupContent(location, lastSeen);
familyMarker.bindPopup(popupContent);
familyMarker.on('popupopen', () => {
familyMarker.closeTooltip();
});
familyMarker.on('popupclose', () => {
familyMarker.openTooltip();
});
this.familyMarkersLayer.addLayer(familyMarker);
this.familyMarkers[location.user_id] = familyMarker;
}
createTooltipContent(lastSeen) {
return `Last updated: ${lastSeen}`;
}
createPopupContent(location, lastSeen) {
const isDark = this.userThemeValue === 'dark';
const bgColor = isDark ? '#1f2937' : '#ffffff';
const textColor = isDark ? '#f9fafb' : '#111827';
const mutedColor = isDark ? '#9ca3af' : '#6b7280';
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
return `
<div class="family-member-popup" style="background-color: ${bgColor}; color: ${textColor}; padding: 12px; border-radius: 8px; min-width: 220px;">
<h3 style="margin: 0 0 12px 0; color: #10B981; font-size: 15px; font-weight: bold; display: flex; align-items: center; gap: 8px;">
<span style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold;">${emailInitial}</span>
Family Member
</h3>
<p style="margin: 0 0 8px 0; font-size: 13px;">
<strong>Email:</strong> ${location.email || 'Unknown'}
</p>
<p style="margin: 0 0 8px 0; font-size: 13px;">
<strong>Coordinates:</strong><br/>
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
</p>
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
<strong>Last updated:</strong> ${lastSeen}
</p>
</div>
`;
}
addToLayerControl() {
// Add family markers layer to the maps controller's layer control
if (window.mapsController && window.mapsController.layerControl && this.familyMarkersLayer) {
// We need to recreate the layer control to include our new layer
this.updateMapsControllerLayerControl();
}
}
updateMapsControllerLayerControl() {
const mapsController = window.mapsController;
if (!mapsController || typeof mapsController.updateLayerControl !== 'function') return;
// Use the maps controller's helper method to update layer control
mapsController.updateLayerControl({
"Family Members": this.familyMarkersLayer
});
}
setupEventListeners() {
// Listen for family data updates (for real-time updates in the future)
document.addEventListener('family:locations:updated', (event) => {
this.familyMemberLocations = event.detail.locations;
this.createFamilyMarkers();
});
// Listen for theme changes
document.addEventListener('theme:changed', (event) => {
this.userThemeValue = event.detail.theme;
// Recreate popups with new theme
this.createFamilyMarkers();
});
// Listen for layer control events
this.setupLayerControlEvents();
}
setupLayerControlEvents() {
if (!this.map) return;
// Listen for when the Family Members layer is added
this.map.on('overlayadd', (event) => {
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
// Refresh locations and zoom after data is loaded
this.refreshFamilyLocations().then(() => {
this.zoomToFitAllMembers();
});
// Set up periodic refresh while layer is active
this.startPeriodicRefresh();
}
});
// Listen for when the Family Members layer is removed
this.map.on('overlayremove', (event) => {
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
// Stop periodic refresh when layer is disabled
this.stopPeriodicRefresh();
}
});
}
zoomToFitAllMembers() {
if (!this.familyMemberBounds || this.familyMemberBounds.length === 0) {
return;
}
// If there's only one member, center on them with a reasonable zoom
if (this.familyMemberBounds.length === 1) {
this.map.setView(this.familyMemberBounds[0], 13);
return;
}
// For multiple members, fit bounds to show all of them
const bounds = L.latLngBounds(this.familyMemberBounds);
this.map.fitBounds(bounds, {
padding: [50, 50], // Add padding around the edges
maxZoom: 15 // Don't zoom in too close
});
}
startPeriodicRefresh() {
// Clear any existing refresh interval
this.stopPeriodicRefresh();
// Refresh family locations every 60 seconds while layer is active (as fallback to real-time)
this.refreshInterval = setInterval(() => {
if (this.map && this.map.hasLayer(this.familyMarkersLayer)) {
this.refreshFamilyLocations();
} else {
// Layer is no longer active, stop refreshing
this.stopPeriodicRefresh();
}
}, 60000); // 60 seconds (real-time updates via ActionCable are primary)
}
stopPeriodicRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
// Method to manually update family member locations (for API calls)
updateFamilyLocations(locations) {
// Convert array to object keyed by user_id
if (Array.isArray(locations)) {
this.familyMemberLocations = {};
locations.forEach(location => {
if (location.user_id) {
this.familyMemberLocations[location.user_id] = location;
}
});
} else {
this.familyMemberLocations = locations;
}
this.createFamilyMarkers();
// Dispatch event for other controllers that might be interested
document.dispatchEvent(new CustomEvent('family:locations:updated', {
detail: { locations: this.familyMemberLocations }
}));
}
// Method to refresh family locations from API
async refreshFamilyLocations() {
if (!window.mapsController?.apiKey) {
console.warn('API key not available for family locations refresh');
return;
}
try {
const response = await fetch(`/api/v1/families/locations?api_key=${window.mapsController.apiKey}`, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
});
if (!response.ok) {
if (response.status === 403) {
console.warn('Family feature not enabled or user not in family');
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.updateFamilyLocations(data.locations || []);
// Show user feedback if this was a manual refresh
if (this.showUserFeedback) {
const count = data.locations?.length || 0;
this.showFlashMessageToUser('notice', `Family locations updated (${count} members)`);
this.showUserFeedback = false; // Reset flag
}
} catch (error) {
console.error('Error refreshing family locations:', error);
// Show error to user if this was a manual refresh
if (this.showUserFeedback) {
this.showFlashMessageToUser('error', 'Failed to refresh family locations');
this.showUserFeedback = false; // Reset flag
}
}
}
// Helper method to show flash messages using the imported helper
showFlashMessageToUser(type, message) {
showFlashMessage(type, message);
}
// Method for manual refresh with user feedback
async manualRefreshFamilyLocations() {
this.showUserFeedback = true; // Enable user feedback for this refresh
await this.refreshFamilyLocations();
}
cleanup() {
// Stop periodic refresh
this.stopPeriodicRefresh();
// Remove family markers layer from map if it exists
if (this.familyMarkersLayer && this.map && this.map.hasLayer(this.familyMarkersLayer)) {
this.map.removeLayer(this.familyMarkersLayer);
}
// Remove map event listeners
if (this.map) {
this.map.off('overlayadd');
this.map.off('overlayremove');
}
// Remove document event listeners
document.removeEventListener('family:locations:updated', this.handleLocationUpdates);
document.removeEventListener('theme:changed', this.handleThemeChange);
}
// Expose layer for external access
getFamilyMarkersLayer() {
return this.familyMarkersLayer;
}
// Check if family features are enabled
isFamilyFeatureEnabled() {
return this.featuresValue.family === true;
}
// Get family marker count
getFamilyMemberCount() {
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
}
}

View file

@ -0,0 +1,48 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["indicator"];
static values = {
enabled: Boolean
};
connect() {
console.log("Family navbar indicator controller connected");
this.updateIndicator();
// Listen for location sharing updates
document.addEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this));
document.addEventListener('location-sharing:expired', this.handleSharingExpired.bind(this));
}
disconnect() {
document.removeEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this));
document.removeEventListener('location-sharing:expired', this.handleSharingExpired.bind(this));
}
handleSharingUpdate(event) {
// Only update if this is the current user's sharing change
// (we're only showing the current user's status in navbar)
this.enabledValue = event.detail.enabled;
this.updateIndicator();
}
handleSharingExpired(event) {
this.enabledValue = false;
this.updateIndicator();
}
updateIndicator() {
if (!this.hasIndicatorTarget) return;
if (this.enabledValue) {
// Green pulsing indicator for enabled
this.indicatorTarget.className = "w-2 h-2 bg-green-500 rounded-full animate-pulse";
this.indicatorTarget.title = "Location sharing enabled";
} else {
// Gray indicator for disabled
this.indicatorTarget.className = "w-2 h-2 bg-gray-400 rounded-full";
this.indicatorTarget.title = "Location sharing disabled";
}
}
}

View file

@ -0,0 +1,276 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["checkbox", "durationContainer", "durationSelect", "expirationInfo"];
static values = {
memberId: Number,
enabled: Boolean,
familyId: Number,
duration: String,
expiresAt: String
};
connect() {
console.log("Location sharing toggle controller connected");
this.updateToggleState();
this.setupExpirationTimer();
}
disconnect() {
this.clearExpirationTimer();
}
toggle() {
const newState = !this.enabledValue;
const duration = this.hasDurationSelectTarget ? this.durationSelectTarget.value : 'permanent';
// Optimistically update UI
this.enabledValue = newState;
this.updateToggleState();
// Send the update to server
this.updateLocationSharing(newState, duration);
}
changeDuration() {
if (!this.enabledValue) return; // Only allow duration changes when sharing is enabled
const duration = this.durationSelectTarget.value;
this.durationValue = duration;
// Update sharing with new duration
this.updateLocationSharing(true, duration);
}
updateToggleState() {
const isEnabled = this.enabledValue;
// Update checkbox (DaisyUI toggle)
this.checkboxTarget.checked = isEnabled;
// Show/hide duration container
if (this.hasDurationContainerTarget) {
if (isEnabled) {
this.durationContainerTarget.classList.remove('hidden');
} else {
this.durationContainerTarget.classList.add('hidden');
}
}
}
async updateLocationSharing(enabled, duration = 'permanent') {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const response = await fetch(`/family/update_location_sharing`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
enabled: enabled,
duration: duration
})
});
const data = await response.json();
if (data.success) {
// Update local values from server response
this.durationValue = data.duration;
this.expiresAtValue = data.expires_at;
// Update duration select if it exists
if (this.hasDurationSelectTarget) {
this.durationSelectTarget.value = data.duration;
}
// Update expiration info
this.updateExpirationInfo(data.expires_at_formatted);
// Show success message
this.showFlashMessage('success', data.message);
// Setup/clear expiration timer
this.setupExpirationTimer();
// Trigger custom event for other controllers to listen to
document.dispatchEvent(new CustomEvent('location-sharing:updated', {
detail: {
userId: this.memberIdValue,
enabled: enabled,
duration: data.duration,
expiresAt: data.expires_at
}
}));
} else {
// Revert the UI change if server update failed
this.enabledValue = !enabled;
this.updateToggleState();
this.showFlashMessage('error', data.message || 'Failed to update location sharing');
}
} catch (error) {
console.error('Error updating location sharing:', error);
// Revert the UI change if request failed
this.enabledValue = !enabled;
this.updateToggleState();
this.showFlashMessage('error', 'Network error occurred while updating location sharing');
}
}
setupExpirationTimer() {
this.clearExpirationTimer();
if (this.enabledValue && this.expiresAtValue) {
const expiresAt = new Date(this.expiresAtValue);
const now = new Date();
const msUntilExpiration = expiresAt.getTime() - now.getTime();
if (msUntilExpiration > 0) {
// Set timer to automatically disable sharing when it expires
this.expirationTimer = setTimeout(() => {
this.enabledValue = false;
this.updateToggleState();
this.showFlashMessage('info', 'Location sharing has expired');
// Trigger update event
document.dispatchEvent(new CustomEvent('location-sharing:expired', {
detail: { userId: this.memberIdValue }
}));
}, msUntilExpiration);
// Also set up periodic updates to show countdown
this.updateExpirationCountdown();
this.countdownInterval = setInterval(() => {
this.updateExpirationCountdown();
}, 60000); // Update every minute
}
}
}
clearExpirationTimer() {
if (this.expirationTimer) {
clearTimeout(this.expirationTimer);
this.expirationTimer = null;
}
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
updateExpirationInfo(formattedTime) {
if (this.hasExpirationInfoTarget && formattedTime) {
this.expirationInfoTarget.textContent = `Expires ${formattedTime}`;
this.expirationInfoTarget.style.display = 'block';
} else if (this.hasExpirationInfoTarget) {
this.expirationInfoTarget.style.display = 'none';
}
}
updateExpirationCountdown() {
if (!this.hasExpirationInfoTarget || !this.expiresAtValue) return;
const expiresAt = new Date(this.expiresAtValue);
const now = new Date();
const msUntilExpiration = expiresAt.getTime() - now.getTime();
if (msUntilExpiration <= 0) {
this.expirationInfoTarget.textContent = 'Expired';
this.expirationInfoTarget.style.display = 'block';
return;
}
const hoursLeft = Math.floor(msUntilExpiration / (1000 * 60 * 60));
const minutesLeft = Math.floor((msUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
let timeText;
if (hoursLeft > 0) {
timeText = `${hoursLeft}h ${minutesLeft}m remaining`;
} else {
timeText = `${minutesLeft}m remaining`;
}
this.expirationInfoTarget.textContent = `Expires in ${timeText}`;
}
showFlashMessage(type, message) {
// Create a flash message element matching the project style (_flash.html.erb)
const flashContainer = document.getElementById('flash-messages') ||
this.createFlashContainer();
const bgClass = this.getFlashClasses(type);
const flashElement = document.createElement('div');
flashElement.className = `flex items-center ${bgClass} py-3 px-5 rounded-lg z-[6000]`;
flashElement.innerHTML = `
<div class="mr-4">${message}</div>
<button type="button">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
`;
// Add click handler to dismiss button
const dismissButton = flashElement.querySelector('button');
dismissButton.addEventListener('click', () => {
flashElement.classList.add('fade-out');
setTimeout(() => {
flashElement.remove();
// Remove the container if it's empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}, 150);
});
flashContainer.appendChild(flashElement);
// Auto-remove after 5 seconds
setTimeout(() => {
if (flashElement.parentNode) {
flashElement.classList.add('fade-out');
setTimeout(() => {
flashElement.remove();
// Remove the container if it's empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}, 150);
}
}, 5000);
}
createFlashContainer() {
const container = document.createElement('div');
container.id = 'flash-messages';
container.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50';
document.body.appendChild(container);
return container;
}
getFlashClasses(type) {
switch (type) {
case 'error':
case 'alert':
return 'bg-red-100 text-red-700 border-red-300';
default:
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
// Helper method to check if user's own location sharing is enabled
// This can be used by other controllers
static getUserLocationSharingStatus() {
const toggleController = document.querySelector('[data-controller*="location-sharing-toggle"]');
if (toggleController) {
const controller = this.application.getControllerForElementAndIdentifier(toggleController, 'location-sharing-toggle');
return controller?.enabledValue || false;
}
return false;
}
}

View file

@ -206,6 +206,9 @@ export default class extends BaseController {
// Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager;
// Expose maps controller globally for family integration
window.mapsController = this;
// Initialize layers for the layer control
const controlsLayer = {
Points: this.markersLayer,
@ -1089,7 +1092,15 @@ export default class extends BaseController {
const TogglePanelControl = L.Control.extend({
onAdd: function(map) {
const button = L.DomUtil.create('button', 'toggle-panel-button');
button.innerHTML = '📅';
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4" />
<path d="M16 2v4" />
<path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8" />
<path d="M3 10h18" />
<path d="m16 20 2 2 4-4" />
</svg>
`;
// Style the button with theme-aware styling
applyThemeToButton(button, controller.userTheme);
@ -1097,9 +1108,9 @@ export default class extends BaseController {
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
@ -1839,4 +1850,77 @@ export default class extends BaseController {
this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme);
}
}
// Helper method for family controller to update layer control
updateLayerControl(additionalLayers = {}) {
if (!this.layerControl) return;
// Store which base and overlay layers are currently visible
const overlayStates = {};
let activeBaseLayer = null;
let activeBaseLayerName = null;
if (this.layerControl._layers) {
Object.values(this.layerControl._layers).forEach(layerObj => {
if (layerObj.overlay && layerObj.layer) {
// Store overlay layer states
overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer);
} else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) {
// Store the currently active base layer
activeBaseLayer = layerObj.layer;
activeBaseLayerName = layerObj.name;
}
});
}
// Remove existing layer control
this.map.removeControl(this.layerControl);
// Create base controls layer object
const baseControlsLayer = {
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Tracks: this.tracksLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.heatLayer([]),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup(),
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
};
// Merge with additional layers (like family members)
const controlsLayer = { ...baseControlsLayer, ...additionalLayers };
// Get base maps and re-add the layer control
const baseMaps = this.baseMaps();
this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map);
// Restore the active base layer if we had one
if (activeBaseLayer && activeBaseLayerName) {
console.log(`Restoring base layer: ${activeBaseLayerName}`);
// Make sure the base layer is added to the map
if (!this.map.hasLayer(activeBaseLayer)) {
activeBaseLayer.addTo(this.map);
}
} else {
// If no active base layer was found, ensure we have a default one
console.log('No active base layer found, adding default');
const defaultBaseLayer = Object.values(baseMaps)[0];
if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) {
defaultBaseLayer.addTo(this.map);
}
}
// Restore overlay layer visibility states
Object.entries(overlayStates).forEach(([name, wasVisible]) => {
const layer = controlsLayer[name];
if (layer && wasVisible && !this.map.hasLayer(layer)) {
layer.addTo(this.map);
}
});
}
}

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Family::Invitations::CleanupJob < ApplicationJob
queue_as :families
def perform
Rails.logger.info 'Starting family invitations cleanup'
expired_count = Family::Invitation.where(status: :pending)
.where('expires_at < ?', Time.current)
.update_all(status: :expired)
Rails.logger.info "Updated #{expired_count} expired family invitations"
cleanup_threshold = 30.days.ago
deleted_count = Family::Invitation.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

@ -0,0 +1,25 @@
# frozen_string_literal: true
class FamilyMailer < ApplicationMailer
def invitation(invitation)
@invitation = invitation
@family = invitation.family
@invited_by = invitation.invited_by
@accept_url = family_invitation_url(@invitation.token)
mail(
to: @invitation.email,
subject: "🎉 You've been invited to join #{@family.name} on Dawarich!"
)
end
def member_joined(family, user)
@family = family
@user = user
mail(
to: @family.owner.email,
subject: "👪 #{@user.name} has joined your family #{@family.name} on Dawarich!"
)
end
end

View file

@ -0,0 +1,116 @@
# frozen_string_literal: true
module UserFamily
extend ActiveSupport::Concern
included do
has_one :family_membership, dependent: :destroy, class_name: 'Family::Membership'
has_one :family, through: :family_membership
has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy
has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
inverse_of: :invited_by, dependent: :destroy
before_destroy :check_family_ownership
end
def in_family?
family_membership.present?
end
def family_owner?
family_membership&.owner? == true
end
def can_delete_account?
return true unless family_owner?
return true unless family
family.members.count <= 1
end
def family_sharing_enabled?
return false unless in_family?
sharing_settings = settings.dig('family', 'location_sharing')
return false unless sharing_settings.is_a?(Hash)
return false unless sharing_settings['enabled'] == true
expires_at = sharing_settings['expires_at']
expires_at.blank? || Time.parse(expires_at).future?
end
def update_family_location_sharing!(enabled, duration: nil)
return false unless in_family?
current_settings = settings || {}
current_settings['family'] ||= {}
if enabled
sharing_config = { 'enabled' => true }
if duration.present?
expiration_time = case duration
when '1h' then 1.hour.from_now
when '6h' then 6.hours.from_now
when '12h' then 12.hours.from_now
when '24h' then 24.hours.from_now
when 'permanent' then nil
else duration.to_i.hours.from_now if duration.to_i > 0
end
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
sharing_config['duration'] = duration
end
current_settings['family']['location_sharing'] = sharing_config
else
current_settings['family']['location_sharing'] = { 'enabled' => false }
end
update!(settings: current_settings)
end
def family_sharing_expires_at
sharing_settings = settings.dig('family', 'location_sharing')
return nil unless sharing_settings.is_a?(Hash)
expires_at = sharing_settings['expires_at']
Time.parse(expires_at) if expires_at.present?
rescue ArgumentError
nil
end
def family_sharing_duration
settings.dig('family', 'location_sharing', 'duration') || 'permanent'
end
def latest_location_for_family
return nil unless family_sharing_enabled?
latest_point =
points.select(:lonlat, :timestamp)
.order(timestamp: :desc)
.limit(1)
.first
return nil unless latest_point
{
user_id: id,
email: email,
latitude: latest_point.lat,
longitude: latest_point.lon,
timestamp: latest_point.timestamp,
updated_at: Time.zone.at(latest_point.timestamp)
}
end
private
def check_family_ownership
return if can_delete_account?
errors.add(:base, 'Cannot delete account while being a family owner with other members')
raise ActiveRecord::DeleteRestrictionError, 'Cannot delete user with family members'
end
end

47
app/models/family.rb Normal file
View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class Family < ApplicationRecord
has_many :family_memberships, dependent: :destroy, class_name: 'Family::Membership'
has_many :members, through: :family_memberships, source: :user
has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation'
belongs_to :creator, class_name: 'User'
validates :name, presence: true, length: { maximum: 50 }
MAX_MEMBERS = 5
def can_add_members?
(member_count + pending_invitations_count) < MAX_MEMBERS
end
def member_count
@member_count ||= members.count
end
def pending_invitations_count
@pending_invitations_count ||= family_invitations.active.count
end
def owners
members.joins(:family_membership)
.where(family_memberships: { role: :owner })
end
def owner
@owner ||= creator
end
def full?
(member_count + pending_invitations_count) >= MAX_MEMBERS
end
def active_invitations
family_invitations.active.includes(:invited_by)
end
def clear_member_cache!
@member_count = nil
@pending_invitations_count = nil
@owner = nil
end
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Family::Invitation < ApplicationRecord
self.table_name = 'family_invitations'
EXPIRY_DAYS = 7
belongs_to :family
belongs_to :invited_by, class_name: 'User'
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token, presence: true, uniqueness: true
validates :expires_at, :status, presence: true
enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 }
scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) }
before_validation :generate_token, :set_expiry, on: :create
after_create :clear_family_cache
after_update :clear_family_cache, if: :saved_change_to_status?
after_destroy :clear_family_cache
def expired?
expires_at.past?
end
def can_be_accepted?
pending? && !expired?
end
private
def generate_token
self.token = SecureRandom.urlsafe_base64(32) if token.blank?
end
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

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Family::Membership < ApplicationRecord
self.table_name = 'family_memberships'
belongs_to :family
belongs_to :user
validates :user_id, presence: true, uniqueness: true
validates :role, presence: true
enum :role, { owner: 0, member: 1 }
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

@ -75,24 +75,49 @@ class Point < ApplicationRecord
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
def broadcast_coordinates
return unless user.safe_settings.live_map_enabled
if user.safe_settings.live_map_enabled
PointsChannel.broadcast_to(
user,
[
lat,
lon,
battery.to_s,
altitude.to_s,
timestamp.to_s,
velocity.to_s,
id.to_s,
country_name.to_s
]
)
end
PointsChannel.broadcast_to(
user,
[
lat,
lon,
battery.to_s,
altitude.to_s,
timestamp.to_s,
velocity.to_s,
id.to_s,
country_name.to_s
]
)
broadcast_to_family if should_broadcast_to_family?
end
# rubocop:enable Metrics/MethodLength
def should_broadcast_to_family?
return false unless DawarichSettings.family_feature_enabled?
return false unless user.in_family?
return false unless user.family_sharing_enabled?
true
end
def broadcast_to_family
FamilyLocationsChannel.broadcast_to(
user.family,
{
user_id: user.id,
email: user.email,
email_initial: user.email.first.upcase,
latitude: lat,
longitude: lon,
timestamp: timestamp.to_i,
updated_at: Time.zone.at(timestamp.to_i).iso8601
}
)
end
def set_country
self.country_id = found_in_country&.id
save! if changed?

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
include UserFamily
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Family::InvitationPolicy < ApplicationPolicy
def create?
return false unless user
user.family == record.family && user.family_owner?
end
def accept?
return false unless user
user.email == record.email
end
def destroy?
create?
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Family::MembershipPolicy < ApplicationPolicy
def create?
return false unless user
return false unless record.is_a?(Family::Invitation)
record.email == user.email && record.pending? && !record.expired?
end
def destroy?
return false unless user
return true if user == record.user
user.family == record.family && user.family_owner?
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class FamilyInvitationPolicy < ApplicationPolicy
def show?
# Public endpoint for invitation acceptance - no authentication required
true
end
def create?
user.family == record.family && user.family_owner?
end
def accept?
# Users can accept invitations sent to their email
user.email == record.email
end
def destroy?
# Only family owners can cancel invitations
user.family == record.family && user.family_owner?
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class FamilyMembershipPolicy < ApplicationPolicy
def show?
user.family == record.family
end
def update?
# Users can update their own settings
return true if user == record.user
# Family owners can update any member's settings
user.family == record.family && user.family_owner?
end
def destroy?
# Users can remove themselves (handled by family leave logic)
return true if user == record.user
# Family owners can remove other members
user.family == record.family && user.family_owner?
end
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
class FamilyPolicy < ApplicationPolicy
def show?
user.family == record
end
def create?
return false if user.in_family?
return true if DawarichSettings.self_hosted?
# Add cloud subscription checks here when implemented
# For now, allow all users to create families
true
end
def update?
user.family == record && user.family_owner?
end
def destroy?
user.family == record && user.family_owner?
end
def leave?
user.family == record && !family_owner_with_members?
end
def invite?
user.family == record && user.family_owner?
end
def manage_invitations?
user.family == record && user.family_owner?
end
private
def family_owner_with_members?
user.family_owner? && record.members.count > 1
end
end

View file

@ -0,0 +1,123 @@
# frozen_string_literal: true
module Families
class AcceptInvitation
attr_reader :invitation, :user, :error_message
def initialize(invitation:, user:)
@invitation = invitation
@user = user
@error_message = nil
end
def call
return false unless can_accept?
if user.in_family?
@error_message = 'You must leave your current family before joining a new one.'
return false
end
ActiveRecord::Base.transaction do
create_membership
update_invitation
send_notifications
end
true
rescue ActiveRecord::RecordInvalid => e
handle_record_invalid_error(e)
false
rescue StandardError => e
handle_generic_error(e)
false
end
private
def can_accept?
return false unless validate_invitation
return false unless validate_email_match
return false unless validate_family_capacity
true
end
def validate_invitation
return true if invitation.can_be_accepted?
@error_message = 'This invitation is no longer valid or has expired.'
false
end
def validate_email_match
return true if invitation.email == user.email
@error_message = 'This invitation is not for your email address.'
false
end
def validate_family_capacity
return true unless invitation.family.full?
@error_message = 'This family has reached the maximum number of members.'
false
end
def create_membership
Family::Membership.create!(
family: invitation.family,
user: user,
role: :member
)
end
def update_invitation
invitation.update!(status: :accepted)
end
def send_notifications
send_user_notification
send_owner_notification
end
def send_user_notification
Notification.create!(
user: user,
kind: :info,
title: 'Welcome to Family!',
content: "You've joined the family '#{invitation.family.name}'"
)
end
def send_owner_notification
Notification.create!(
user: invitation.family.creator,
kind: :info,
title: 'New Family Member!',
content: "#{user.email} has joined your family"
)
rescue StandardError => e
ExceptionReporter.call(e, "Unexpected error in Families::AcceptInvitation: #{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)
ExceptionReporter.call(error, "Unexpected error in Families::AcceptInvitation: #{error.message}")
@error_message = 'An unexpected error occurred while joining the family. Please try again'
end
end
end

View file

@ -0,0 +1,126 @@
# frozen_string_literal: true
module Families
class Create
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&.strip
@error_message = nil
end
def call
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
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?
# TODO: Add cloud plan validation here when needed
# For now, allow all users to create families
true
end
def create_family
@family = Family.create!(name: name, creator: user)
end
def create_owner_membership
Family::Membership.create!(
family: family,
user: user,
role: :owner
)
end
def send_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
ExceptionReporter.call(e, "Unexpected error in Families::Create: #{e.message}")
end
def handle_record_invalid_error(error)
@error_message =
if family&.errors&.any?
family.errors.full_messages.first
else
"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)
ExceptionReporter.call(error, "Unexpected error in Families::Create: #{error.message}")
@error_message = 'An unexpected error occurred while creating the family. Please try again'
end
end
end

View file

@ -0,0 +1,125 @@
# frozen_string_literal: true
module Families
class Invite
include ActiveModel::Validations
attr_reader :family, :email, :invited_by, :invitation
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
def initialize(family:, email:, invited_by:)
@family = family
@email = email.downcase.strip
@invited_by = invited_by
end
def call
return false unless valid?
return false unless invite_sendable?
ActiveRecord::Base.transaction do
create_invitation
send_invitation_email
send_notification
end
true
rescue ActiveRecord::RecordInvalid => e
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
private
def invite_sendable?
unless invited_by.family_owner?
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.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?
true
end
def add_error_and_false(attribute, message)
errors.add(attribute, message)
false
end
def user_already_in_family?
User.joins(:family_membership)
.where(email: email)
.exists?
end
def pending_invitation_exists?
family.family_invitations.active.where(email: email).exists?
end
def create_invitation
@invitation = Family::Invitation.create!(
family: family,
email: email,
invited_by: invited_by
)
end
def send_invitation_email
# Send email in background with retry logic
FamilyMailer.invitation(@invitation).deliver_later(
queue: :mailer,
retry: 3,
wait: 30.seconds
)
end
def send_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
ExceptionReporter.call(e, "Unexpected error in Families::Invite: #{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)
ExceptionReporter.call(error, "Unexpected error in Families::Invite: #{error.message}")
@custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again'
end
end
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
class Families::Locations
attr_reader :user
def initialize(user)
@user = user
end
def call
return [] unless family_feature_enabled?
return [] unless user.in_family?
sharing_members = family_members_with_sharing_enabled
return [] unless sharing_members.any?
build_family_locations(sharing_members)
end
private
def family_feature_enabled?
DawarichSettings.family_feature_enabled?
end
def family_members_with_sharing_enabled
user.family.members
.where.not(id: user.id)
.select(&:family_sharing_enabled?)
end
def build_family_locations(sharing_members)
latest_points =
sharing_members.map { _1.points.last }.compact
latest_points.map do |point|
{
user_id: point.user_id,
email: point.user.email,
email_initial: point.user.email.first.upcase,
latitude: point.lat,
longitude: point.lon,
timestamp: point.timestamp.to_i,
updated_at: Time.zone.at(point.timestamp.to_i)
}
end
end
end

View file

@ -0,0 +1,157 @@
# frozen_string_literal: true
module Families
module Memberships
class Destroy
attr_reader :user, :member_to_remove, :error_message
def initialize(user:, member_to_remove: nil)
@user = user
@member_to_remove = member_to_remove || user
@error_message = nil
end
def call
return false unless validate_can_leave
@family_name = member_to_remove.family.name
@family_owner = member_to_remove.family.owner
ActiveRecord::Base.transaction do
remove_membership
send_notifications
end
true
rescue ActiveRecord::RecordInvalid => e
handle_record_invalid_error(e)
false
rescue StandardError => e
handle_generic_error(e)
false
end
private
def validate_can_leave
return false unless validate_in_family
return false unless validate_removal_allowed
true
end
def validate_in_family
return true if member_to_remove.in_family?
@error_message = 'User is not currently in a family.'
false
end
def validate_removal_allowed
return validate_owner_can_leave if removing_self?
return false unless validate_remover_is_owner
return false unless validate_same_family
return false unless validate_not_removing_owner
true
end
def removing_self?
user == member_to_remove
end
def validate_owner_can_leave
return true unless member_to_remove.family_owner?
@error_message = 'Family owners cannot remove their own membership. To leave the family, delete it instead.'
false
end
def validate_remover_is_owner
return true if user.family_owner?
@error_message = 'Only family owners can remove other members.'
false
end
def validate_same_family
return true if user.family == member_to_remove.family
@error_message = 'Cannot remove members from a different family.'
false
end
def validate_not_removing_owner
return true unless member_to_remove.family_owner?
@error_message = 'Cannot remove the family owner. The owner must delete the family or leave on their own.'
false
end
def remove_membership
member_to_remove.family_membership.destroy!
end
def send_notifications
if removing_self?
send_self_removal_notifications
else
send_member_removed_notifications
end
end
def send_self_removal_notifications
Notification.create!(
user: member_to_remove,
kind: :info,
title: 'Left Family',
content: "You've left the family \"#{@family_name}\""
)
return unless @family_owner&.persisted?
Notification.create!(
user: @family_owner,
kind: :info,
title: 'Family Member Left',
content: "#{member_to_remove.email} has left the family \"#{@family_name}\""
)
end
def send_member_removed_notifications
Notification.create!(
user: member_to_remove,
kind: :info,
title: 'Removed from Family',
content: "You have been removed from the family \"#{@family_name}\" by #{user.email}"
)
return unless user != member_to_remove
Notification.create!(
user: user,
kind: :info,
title: 'Member Removed',
content: "#{member_to_remove.email} has been removed from the family \"#{@family_name}\""
)
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)
ExceptionReporter.call(error, "Unexpected error in Families::Memberships::Destroy: #{error.message}")
@error_message = 'An unexpected error occurred while removing the membership. Please try again'
end
end
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
class Families::UpdateLocationSharing
Result = Struct.new(:success?, :payload, :status, keyword_init: true)
def initialize(user:, enabled:, duration:)
@user = user
@enabled_param = enabled
@duration_param = duration
@boolean_caster = ActiveModel::Type::Boolean.new
end
def call
return success_result if update_location_sharing
failure_result('Failed to update location sharing setting', :unprocessable_content)
rescue => error
ExceptionReporter.call(error, "Error in Families::UpdateLocationSharing: #{error.message}")
failure_result('An error occurred while updating location sharing', :internal_server_error)
end
private
attr_reader :user, :enabled_param, :duration_param, :boolean_caster
def update_location_sharing
user.update_family_location_sharing!(enabled?, duration: duration_param)
end
def enabled?
@enabled ||= boolean_caster.cast(enabled_param)
end
def success_result
payload = {
success: true,
enabled: enabled?,
duration: user.family_sharing_duration,
message: build_sharing_message
}
if enabled? && user.family_sharing_expires_at.present?
payload[:expires_at] = user.family_sharing_expires_at.iso8601
payload[:expires_at_formatted] = user.family_sharing_expires_at.strftime('%b %d at %I:%M %p')
end
Result.new(success?: true, payload: payload, status: :ok)
end
def failure_result(message, status)
Result.new(success?: false, payload: { success: false, message: message }, status: status)
end
def build_sharing_message
return 'Location sharing disabled' unless enabled?
case duration_param
when '1h' then 'Location sharing enabled for 1 hour'
when '6h' then 'Location sharing enabled for 6 hours'
when '12h' then 'Location sharing enabled for 12 hours'
when '24h' then 'Location sharing enabled for 24 hours'
when 'permanent', nil then 'Location sharing enabled'
else
duration_param.to_i.positive? ? "Location sharing enabled for #{duration_param.to_i} hours" : 'Location sharing enabled'
end
end
end

View file

@ -1,16 +1,38 @@
<div class="hero min-h-content bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Register now!</h1>
<p class="py-6">and take control over your location data.</p>
<% if @invitation %>
<h1 class="text-5xl font-bold text-base-content">Join <%= @invitation.family.name %>!</h1>
<p class="py-6 text-base-content opacity-70">
You've been invited by <strong><%= @invitation.invited_by.email %></strong> to join their family.
Create your account to accept the invitation and start sharing location data.
</p>
<div class="alert alert-info mb-4">
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
<span class="text-sm">
Your email (<%= @invitation.email %>) will be used for this account
</span>
</div>
<% else %>
<h1 class="text-5xl font-bold text-base-content">Register now!</h1>
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
<% end %>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<% if @invitation %>
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
<% end %>
<div class="form-control">
<%= f.label :email, class: 'label' do %>
<span class="label-text">Email</span>
<% end %>
<%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'input input-bordered' %>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
readonly: @invitation.present?,
class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
</div>
<div class="form-control">
@ -18,17 +40,17 @@
<span class="label-text">Password</span>
<% end %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %>
</div>
<div class="form-control">
<%= f.label :password_confirmation, class: 'label' do %>
<span class="label-text">Password</span>
<span class="label-text">Password Confirmation</span>
<% end %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %>
</div>
@ -38,10 +60,13 @@
<% end %>
<div class="form-control mt-6">
<%= f.submit "Sign up", class: 'btn btn-primary' %>
<%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"),
class: 'btn btn-primary' %>
</div>
<%= render "devise/shared/links" %>
<% unless @invitation %>
<%= render "devise/shared/links" %>
<% end %>
<% end %>
</div>
</div>

View file

@ -1,16 +1,29 @@
<div class="hero min-h-content bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Login now</h1>
<p class="py-6">and take control over your location data.</p>
<% if ENV['DEMO_ENV'] == 'true' %>
<p class="py-6">
Demo account: <strong class="text-success">demo@dawarich.app</strong> / password: <strong class="text-success">password</strong>
<% if @invitation %>
<h1 class="text-5xl font-bold text-base-content">Sign in to join <%= @invitation.family.name %>!</h1>
<p class="py-6 text-base-content opacity-70">
You've been invited by <strong><%= @invitation.invited_by.email %></strong> to join their family.
Sign in to your account to accept the invitation.
</p>
<div class="alert alert-info">
<p class="text-sm">
Don't have an account yet?
<%= link_to "Create one here", new_user_registration_path(invitation_token: @invitation.token), class: "font-semibold underline" %>
</p>
</div>
<% else %>
<h1 class="text-5xl font-bold text-base-content">Login now</h1>
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
<% end %>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<% if @invitation %>
<%= hidden_field_tag :invitation_token, params[:invitation_token] %>
<% end %>
<div class="form-control">
<%= f.label :email, class: 'label' do %>
<span class="label-text">Email</span>
@ -32,10 +45,12 @@
<% end %>
</div>
<div class="form-control mt-6">
<%= f.submit "Log in", class: 'btn btn-primary' %>
<%= f.submit (@invitation ? "Sign in & Accept Invitation" : "Log in"), class: 'btn btn-primary' %>
</div>
<%= render "devise/shared/links" %>
<% unless @invitation %>
<%= render "devise/shared/links" %>
<% end %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,99 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="bg-base-200 rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-base-content">
<%= t('families.edit.title', default: 'Edit Family') %>
</h1>
<%= link_to family_path,
class: "btn btn-ghost" do %>
<%= t('families.edit.back', default: '← Back to Family') %>
<% end %>
</div>
<%= form_with model: @family, local: true, class: "space-y-6" do |form| %>
<% if @family.errors.any? %>
<div class="alert alert-error">
<div>
<h3 class="text-sm font-medium">
<%= t('families.edit.error_title', default: 'There were problems with your submission:') %>
</h3>
<div class="mt-2 text-sm">
<ul class="list-disc pl-5 space-y-1">
<% @family.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div>
<%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %>
<%= form.text_field :name,
class: "input input-bordered w-full",
placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %>
<p class="mt-1 text-sm text-base-content opacity-50">
<%= t('families.edit.name_help', default: 'Choose a name that all family members will recognize.') %>
</p>
</div>
<div class="bg-base-300 p-4 rounded-md">
<h3 class="text-sm font-medium text-base-content mb-2">
<%= t('families.edit.family_info', default: 'Family Information') %>
</h3>
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-base-content opacity-60">
<%= t('families.edit.creator', default: 'Created by') %>
</dt>
<dd class="text-sm text-base-content"><%= @family.creator.email %></dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content opacity-60">
<%= t('families.edit.created_on', default: 'Created on') %>
</dt>
<dd class="text-sm text-base-content"><%= @family.created_at.strftime('%B %d, %Y') %></dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content opacity-60">
<%= t('families.edit.members_count', default: 'Members') %>
</dt>
<dd class="text-sm text-base-content">
<%= pluralize(@family.members.count, 'member') %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content opacity-60">
<%= t('families.edit.last_updated', default: 'Last updated') %>
</dt>
<dd class="text-sm text-base-content"><%= @family.updated_at.strftime('%B %d, %Y') %></dd>
</div>
</dl>
</div>
<div class="flex items-center justify-between pt-4">
<div class="flex space-x-3">
<%= form.submit t('families.edit.save_changes', default: 'Save Changes'),
class: "btn btn-primary" %>
<%= link_to family_path,
class: "btn btn-neutral" do %>
<%= t('families.edit.cancel', default: 'Cancel') %>
<% end %>
</div>
<% if policy(@family).destroy? %>
<%= link_to family_path,
method: :delete,
data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
class: "btn btn-outline btn-error" do %>
<%= icon 'trash-2', class: "inline-block w-4" %>
Delete Family
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,47 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-base-content mb-4">
<%= t('families.index.title', default: 'Family Management') %>
</h1>
<p class="text-base-content opacity-60">
<%= t('families.index.description', default: 'Create or join a family to share your location data with loved ones.') %>
</p>
</div>
<div class="bg-base-200 rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4 text-base-content">
<%= t('families.index.create_family', default: 'Create Your Family') %>
</h2>
<%= form_with model: Family.new, local: true, class: "space-y-4" do |form| %>
<div>
<%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-1" %>
<%= form.text_field :name,
placeholder: t('families.form.name_placeholder', default: 'Enter your family name'),
class: "input input-bordered w-full" %>
</div>
<div class="flex justify-end">
<%= form.submit t('families.form.create', default: 'Create Family'),
class: "btn btn-primary" %>
</div>
<% end %>
</div>
<div class="mt-8 text-center">
<h3 class="text-lg font-medium text-base-content mb-4">
<%= t('families.index.have_invitation', default: 'Have an invitation?') %>
</h3>
<p class="text-base-content opacity-60 mb-4">
<%= t('families.index.invitation_instructions', default: 'If someone has invited you to join their family, you should have received an email with an invitation link.') %>
</p>
<div class="text-sm text-base-content opacity-50">
<%= t('families.index.invitation_help', default: 'Check your email for an invitation link that looks like: ') %>
<code class="bg-base-300 text-base-content px-2 py-1 rounded text-xs">
<%= "#{request.base_url}/invitations/..." %>
</code>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,66 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-base-content mb-4">
<%= t('families.new.title', default: 'Create Your Family') %>
</h1>
<p class="text-base-content opacity-60">
<%= t('families.new.description', default: 'Create a family to share your location data with your loved ones.') %>
</p>
</div>
<div class="bg-base-200 rounded-lg p-6">
<%= form_with url: family_path, model: @family, local: true, class: "space-y-6" do |form| %>
<% if @family.errors.any? %>
<div class="alert alert-error">
<div>
<h3 class="text-sm font-medium">
<%= t('families.new.error_title', default: 'There were problems with your submission:') %>
</h3>
<div class="mt-2 text-sm">
<ul class="list-disc pl-5 space-y-1">
<% @family.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div>
<%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %>
<%= form.text_field :name,
class: "input input-bordered w-full",
placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %>
<p class="mt-1 text-sm text-base-content opacity-50">
<%= t('families.new.name_help', default: 'Choose a name that all family members will recognize, like "The Smith Family" or "Our Travel Group".') %>
</p>
</div>
<div class="alert alert-info">
<div>
<h3 class="text-sm font-medium mb-2">
<%= t('families.new.what_happens_title', default: 'What happens next?') %>
</h3>
<ul class="text-sm space-y-1">
<li>• <%= t('families.new.what_happens_1', default: 'You will become the family owner') %></li>
<li>• <%= t('families.new.what_happens_2', default: 'You can invite others to join your family') %></li>
<li>• <%= t('families.new.what_happens_3', default: 'Family members can view shared location data') %></li>
<li>• <%= t('families.new.what_happens_4', default: 'You can manage family settings and members') %></li>
</ul>
</div>
</div>
<div class="flex items-center justify-between">
<%= form.submit t('families.new.create_family', default: 'Create Family'),
class: "btn btn-primary" %>
<%= link_to root_path,
class: "btn btn-ghost" do %>
<%= t('families.new.back', default: '← Back') %>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,249 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<!-- Family Header -->
<div class="bg-base-200 rounded-lg p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-base-content"><%= @family.name %></h1>
<p class="text-base-content opacity-60 mt-1">
<%= t('families.show.created_by', default: 'Created by') %>
<%= @family.creator.email %>
<%= t('families.show.on_date', default: 'on') %>
<%= @family.created_at.strftime('%B %d, %Y') %>
</p>
</div>
<div class="flex space-x-3">
<% if policy(@family).update? %>
<%= link_to edit_family_path,
class: "btn btn-outline btn-info" do %>
<%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %>
<% end %>
<% end %>
<% if !current_user.family_owner? && current_user.family_membership %>
<%= link_to family_member_path(current_user.family_membership),
method: :delete,
data: { confirm: 'Are you sure you want to leave this family?', turbo_confirm: 'Are you sure you want to leave this family?' },
class: "btn btn-outline btm-sm btn-warning" do %>
Leave Family
<% end %>
<% end %>
<% if policy(@family).destroy? %>
<%= link_to family_path,
method: :delete,
data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
class: "btn btn-outline btm-sm btn-error" do %>
<%= icon 'trash-2', class: "inline-block w-4" %>
Delete
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Family Members -->
<div class="bg-base-200 rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-base-content">
<%= t('families.show.members_title', default: 'Family Members') %>
<span class="text-sm font-normal opacity-50">(<%= @members.count %>)</span>
</h2>
</div>
<div class="space-y-3">
<% @members.each do |member| %>
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div class="flex-grow">
<!-- Member Info -->
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-12">
<span class="text-lg font-semibold">
<%= member.email&.first&.upcase || '?' %>
</span>
</div>
</div>
<div>
<h3 class="card-title text-base"><%= member.email %></h3>
<div class="flex items-center gap-2 mt-1">
<% if member.family_membership.role == 'owner' %>
<div class="badge badge-warning badge-sm">
<%= t('families.show.owner_badge', default: 'Owner') %>
</div>
<% else %>
<span class="text-sm opacity-60">
<%= member.family_membership.role.humanize %>
</span>
<% end %>
</div>
<div class="text-xs opacity-50 mt-1">
<%= t('families.show.joined_on', default: 'Joined') %>
<%= member.family_membership.created_at.strftime('%b %d, %Y') %>
</div>
</div>
</div>
</div>
<!-- Location Sharing Controls - More Compact -->
<div class="ml-auto flex items-center gap-4">
<% if member == current_user %>
<!-- Own toggle - interactive (consolidated controller) -->
<div data-controller="location-sharing-toggle"
data-location-sharing-toggle-member-id-value="<%= member.id %>"
data-location-sharing-toggle-enabled-value="<%= member.family_sharing_enabled? %>"
data-location-sharing-toggle-family-id-value="<%= @family.id %>"
data-location-sharing-toggle-duration-value="<%= member.family_sharing_duration %>"
data-location-sharing-toggle-expires-at-value="<%= member.family_sharing_expires_at&.iso8601 %>"
class="flex items-center gap-3">
<span class="text-sm opacity-60">Location:</span>
<!-- Toggle Switch -->
<input type="checkbox"
class="toggle toggle-primary toggle-sm"
<%= 'checked' if member.family_sharing_enabled? %>
data-location-sharing-toggle-target="checkbox"
data-action="change->location-sharing-toggle#toggle">
<!-- Duration Dropdown (only visible when enabled) -->
<div class="<%= 'hidden' unless member.family_sharing_enabled? %>"
data-location-sharing-toggle-target="durationContainer">
<select class="select select-bordered select-xs w-28 h-full"
data-location-sharing-toggle-target="durationSelect"
data-action="change->location-sharing-toggle#changeDuration">
<option value="permanent" <%= 'selected' if member.family_sharing_duration == 'permanent' %>>Always</option>
<option value="1h" <%= 'selected' if member.family_sharing_duration == '1h' %>>1 hour</option>
<option value="6h" <%= 'selected' if member.family_sharing_duration == '6h' %>>6 hours</option>
<option value="12h" <%= 'selected' if member.family_sharing_duration == '12h' %>>12 hours</option>
<option value="24h" <%= 'selected' if member.family_sharing_duration == '24h' %>>24 hours</option>
</select>
</div>
<!-- Expiration Info (inline) -->
<% if member.family_sharing_enabled? && member.family_sharing_expires_at.present? %>
<div class="text-xs opacity-50"
data-location-sharing-toggle-target="expirationInfo">
• Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now
</div>
<% end %>
</div>
<% else %>
<!-- Other member's status - read-only indicator -->
<div class="flex items-center gap-2">
<span class="text-sm opacity-60">Location:</span>
<% if member.family_sharing_enabled? %>
<div class="w-3 h-3 bg-success rounded-full animate-pulse"></div>
<span class="text-xs text-success font-medium">
<%= member.family_sharing_duration == 'permanent' ? 'Always' : member.family_sharing_duration&.upcase %>
</span>
<% if member.family_sharing_expires_at.present? %>
<span class="text-xs opacity-50">
• Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now
</span>
<% end %>
<% else %>
<div class="w-3 h-3 bg-base-300 rounded-full"></div>
<span class="text-xs opacity-50">Disabled</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
<!-- Pending Invitations -->
<div class="bg-base-200 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-base-content">
<%= t('families.show.invitations_title', default: 'Pending Invitations') %>
<span class="text-sm font-normal opacity-50">(<%= @pending_invitations.count %>)</span>
</h2>
</div>
<% if @pending_invitations.any? %>
<div class="space-y-3 mb-4">
<% @pending_invitations.each do |invitation| %>
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg">
<div>
<div class="font-medium text-base-content"><%= invitation.email %></div>
<div class="text-sm text-base-content opacity-60">
<%= t('families.show.invited_on', default: 'Invited') %>
<%= invitation.created_at.strftime('%b %d, %Y') %>
</div>
<div class="text-xs text-base-content opacity-50">
<%= t('families.show.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
</div>
</div>
<% if policy(@family).manage_invitations? %>
<%= link_to family_invitation_path(invitation.token),
method: :delete,
data: { confirm: 'Are you sure you want to cancel this invitation?', turbo_confirm: 'Are you sure you want to cancel this invitation?' },
class: "btn btn-outline btn-warning btn-sm opacity-70" do %>
Cancel
<% end %>
<% end %>
</div>
<% end %>
</div>
<% else %>
<p class="text-base-content opacity-50 text-center py-4">
<%= t('families.show.no_pending_invitations', default: 'No pending invitations') %>
</p>
<% end %>
<!-- Invite New Member -->
<% if policy(@family).invite? && @family.can_add_members? %>
<div class="border-t pt-4">
<h3 class="text-lg font-medium text-base-content mb-3">
<%= t('families.show.invite_member', default: 'Invite New Member') %>
</h3>
<%= form_with model: [@family, Family::Invitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %>
<div>
<%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "label label-text font-medium mb-1" %>
<%= form.email_field :email,
placeholder: t('families.show.email_placeholder', default: 'Enter email address'),
class: "input input-bordered w-full" %>
</div>
<div class="flex justify-end">
<%= form.submit t('families.show.send_invitation', default: 'Send Invitation'),
class: "btn btn-primary" %>
</div>
<% end %>
</div>
<% elsif policy(@family).invite? %>
<!-- Family at capacity message -->
<div class="border-t pt-4">
<div class="alert alert-warning">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
<div>
<h3 class="text-sm font-medium">
Family at Capacity
</h3>
<div class="mt-2 text-sm">
<p>
Your family has reached the maximum of <%= @family.class::MAX_MEMBERS %> members (including pending invitations).
Cancel existing invitations or wait for them to expire to invite new members.
</p>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,57 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<div class="bg-base-200 rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-base-content">
<%= t('family_invitations.index.title', default: 'Family Invitations') %>
</h1>
<%= link_to family_path,
class: "btn btn-neutral" do %>
<%= t('family_invitations.index.back_to_family', default: 'Back to Family') %>
<% end %>
</div>
<% if @pending_invitations.any? %>
<div class="space-y-4">
<% @pending_invitations.each do |invitation| %>
<div class="flex items-center justify-between p-4 bg-base-100 rounded-lg">
<div>
<div class="font-medium text-base-content"><%= invitation.email %></div>
<div class="text-sm text-base-content opacity-60">
<%= t('family_invitations.index.invited_on', default: 'Invited') %>
<%= invitation.created_at.strftime('%B %d, %Y') %>
</div>
<div class="text-xs text-base-content opacity-50">
<%= t('family_invitations.index.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %>
</div>
</div>
<div class="flex space-x-2">
<%= link_to public_invitation_path(invitation.token),
class: "btn btn-ghost btn-sm text-info" do %>
<%= t('family_invitations.index.view_invitation', default: 'View') %>
<% end %>
<% if policy(@family).manage_invitations? %>
<%= link_to family_invitation_path(invitation.token),
method: :delete,
confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'),
class: "btn btn-ghost btn-sm text-error" do %>
<%= t('family_invitations.index.cancel', default: 'Cancel') %>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-8">
<p class="text-base-content opacity-50 text-lg">
<%= t('family_invitations.index.no_invitations', default: 'No pending invitations') %>
</p>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,164 @@
<div class="min-h-screen bg-gradient-to-br from-base-100 to-base-200 py-12 px-4 mx-auto">
<div class="max-w-4xl mx-auto">
<!-- Hero Section -->
<div class="text-center mb-12">
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-primary mb-6 shadow-xl">
<%= icon 'users', class: "h-12 w-12 text-primary-content" %>
</div>
<h1 class="text-5xl font-bold text-base-content mb-4">
Join <%= @invitation.family.name %>!
</h1>
<p class="text-xl text-base-content opacity-80 mb-2">
You've been invited by <strong class="text-base-content"><%= @invitation.invited_by.email %></strong> to join their family. Create your account to accept the invitation and start sharing location data.
</p>
<div class="alert alert-info inline-flex rounded-lg px-4 py-2 mt-4">
<%= icon 'info', class: "h-5 w-5 mr-2" %>
<span class="text-sm font-medium">
Your email (<%= @invitation.email %>) will be used for this account
</span>
</div>
</div>
<!-- Benefits Section -->
<div class="bg-base-200 shadow-xl rounded-2xl p-8 mb-8">
<h2 class="text-2xl font-bold text-base-content mb-6 text-center">
What benefits does joining a family bring?
</h2>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="flex items-start space-x-4 p-4 bg-info/10 rounded-lg border border-info/20">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-info flex items-center justify-center">
<%= icon 'map-pin', class: "h-6 w-6 text-info-content" %>
</div>
</div>
<div>
<h3 class="font-semibold text-base-content mb-1">
Share Location Data
</h3>
<p class="text-sm text-base-content opacity-70">
Share your location history with family members and see where they are
</p>
</div>
</div>
<div class="flex items-start space-x-4 p-4 bg-secondary/10 rounded-lg border border-secondary/20">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
<%= icon 'chart-column', class: "h-6 w-6 text-secondary-content" %>
</div>
</div>
<div>
<h3 class="font-semibold text-base-content mb-1">
Track your location history
</h3>
<p class="text-sm text-base-content opacity-70">
Access interactive maps and personal travel statistics
</p>
</div>
</div>
<div class="flex items-start space-x-4 p-4 bg-success/10 rounded-lg border border-success/20">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-success flex items-center justify-center">
<%= icon 'heart', class: "h-6 w-6 text-success-content" %>
</div>
</div>
<div>
<h3 class="font-semibold text-base-content mb-1">
Stay Connected
</h3>
<p class="text-sm text-base-content opacity-70">
Keep track of your loved ones' travels and adventures in real-time
</p>
</div>
</div>
<div class="flex items-start space-x-4 p-4 bg-warning/10 rounded-lg border border-warning/20">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-warning flex items-center justify-center">
<%= icon 'shield-check', class: "h-6 w-6 text-warning-content" %>
</div>
</div>
<div>
<h3 class="font-semibold text-base-content mb-1">
Full Control & Privacy
</h3>
<p class="text-sm text-base-content opacity-70">
You control what and how long you share and can leave the family anytime
</p>
</div>
</div>
</div>
<!-- Invitation Details -->
<div class="bg-base-300 rounded-lg p-6 mb-6">
<h3 class="text-sm font-medium text-base-content opacity-70 mb-3">Invitation Details</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-base-content opacity-60">Family:</span>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.family.name %></span>
</div>
<div>
<span class="text-base-content opacity-60">Invited by:</span>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.invited_by.email %></span>
</div>
<div>
<span class="text-base-content opacity-60">Your email:</span>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.email %></span>
</div>
<div>
<span class="text-base-content opacity-60">Expires:</span>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.expires_at.strftime('%b %d, %Y') %></span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-4">
<% if user_signed_in? %>
<!-- User is logged in, show accept button -->
<%= link_to accept_family_invitation_path(token: @invitation.token),
method: :post,
class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %>
✓ Accept Invitation & Join Family
<% end %>
<p class="text-sm text-base-content opacity-60 text-center">
Logged in as <%= current_user.email %>
·
<%= link_to destroy_user_session_path, method: :delete, class: "link link-info" do %>
Logout
<% end %>
</p>
<% else %>
<!-- User is not logged in, show register button prominently -->
<%= link_to new_user_registration_path(invitation_token: @invitation.token),
class: "btn btn-primary btn-lg w-full text-lg shadow-lg" do %>
Create Account & Join Family →
<% end %>
<div class="text-center">
<p class="text-sm text-gray-300 mb-2">
Already have an account?
</p>
<%= link_to new_user_session_path(invitation_token: @invitation.token),
class: "link link-info font-medium" do %>
Sign in to accept invitation
<% end %>
</div>
<% end %>
<!-- Decline Option -->
<div class="pt-6 border-t border-base-300 text-center">
<p class="text-sm text-base-content opacity-60">
Not interested? You can simply close this page.
</p>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,48 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
<div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin-bottom: 20px; text-align: center;">You've been invited to join a family!</h2>
<p style="color: #374151; line-height: 1.6;">Hi there!</p>
<p style="color: #374151; line-height: 1.6;">
<strong><%= @invited_by.email %></strong> has invited you to join their family
"<strong><%= @family.name %></strong>" on Dawarich.
</p>
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 6px; margin: 20px 0;">
<h3 style="color: #1f2937; margin-bottom: 15px; font-size: 18px;">By joining this family, you'll be able to:</h3>
<ul style="color: #374151; line-height: 1.6; margin: 0; padding-left: 20px;">
<li style="margin-bottom: 8px;">Share your current location with family members</li>
<li style="margin-bottom: 8px;">See the current location of other family members</li>
<li style="margin-bottom: 8px;">Stay connected with your loved ones</li>
<li>Control your privacy with full sharing controls</li>
</ul>
</div>
<div style="text-align: center; margin: 30px 0;">
<%= link_to "Accept Invitation", @accept_url,
style: "background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 600;" %>
</div>
<div style="background-color: #fef3cd; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; color: #92400e; font-size: 14px;">
<strong>⏰ Important:</strong> This invitation will expire in 7 days.
</p>
</div>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation.
</p>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
If you didn't expect this invitation, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
Best regards,<br>
Evgenii from Dawarich
</p>
</div>
</div>

View file

@ -0,0 +1,22 @@
You've been invited to join a family!
Hi there!
<%= @invited_by.email %> has invited you to join their family "<%= @family.name %>" on Dawarich.
By joining this family, you'll be able to:
• Share your current location with family members
• See the current location of other family members
• Stay connected with your loved ones
• Control your privacy with full sharing controls
Accept your invitation here: <%= @accept_url %>
IMPORTANT: This invitation will expire in 7 days.
If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation.
If you didn't expect this invitation, you can safely ignore this email.
Best regards,
Evgenii from Dawarich

View file

@ -0,0 +1,39 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
<div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin-bottom: 20px; text-align: center;">🎉 Great news! Someone joined your family!</h2>
<p style="color: #374151; line-height: 1.6;">Hi <%= @family.owner.email %>!</p>
<p style="color: #374151; line-height: 1.6;">
We're excited to let you know that <strong><%= @user.email %></strong> has just joined your family
"<strong><%= @family.name %></strong>" on Dawarich!
</p>
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 6px; margin: 20px 0;">
<h3 style="color: #1f2937; margin-bottom: 15px; font-size: 18px;">Now you can:</h3>
<ul style="color: #374151; line-height: 1.6; margin: 0; padding-left: 20px;">
<li style="margin-bottom: 8px;">See <%= @user.email %>'s current location (if they've enabled sharing)</li>
<li style="margin-bottom: 8px;">Stay connected with your growing family</li>
<li style="margin-bottom: 8px;">Share your location with <%= @user.email %></li>
<li>Manage family members and settings from your family page</li>
</ul>
</div>
<div style="background-color: #dbeafe; border: 1px solid #3b82f6; border-radius: 6px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; color: #1e40af; font-size: 14px;">
<strong>💡 Tip:</strong> You can manage your family members and privacy settings at any time from your family dashboard.
</p>
</div>
<p style="color: #374151; line-height: 1.6;">
Your family now has <strong><%= @family.member_count %></strong> member<%= @family.member_count == 1 ? '' : 's' %>.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
Best regards,<br>
Evgenii from Dawarich
</p>
</div>
</div>

View file

@ -0,0 +1,18 @@
Great news! Someone joined your family!
Hi <%= @family.owner.email %>!
We're excited to let you know that <%= @user.email %> has just joined your family "<%= @family.name %>" on Dawarich!
Now you can:
• See <%= @user.email %>'s current location (if they've enabled sharing)
• Stay connected with your growing family
• Share your location with <%= @user.email %>
• Manage family members and settings from your family page
TIP: You can manage your family members and privacy settings at any time from your family dashboard.
Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>.
Best regards,
Evgenii from Dawarich

View file

@ -9,7 +9,7 @@
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
◀️
<%= icon 'chevron-left' %>
<% end %>
</span>
</div>
@ -30,7 +30,7 @@
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
▶️
<%= icon 'chevron-right' %>
<% end %>
</span>
</div>
@ -63,7 +63,7 @@
<div
id='map'
class="w-full z-0"
data-controller="maps points add-visit"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
@ -74,7 +74,9 @@
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-features='<%= @features.to_json.html_safe %>'>
data-features='<%= @features.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen z-0">
<div id="fog" class="fog"></div>
</div>

View file

@ -1,12 +1,18 @@
<div class="fixed top-5 right-5 flex flex-col gap-2 z-50" id="flash-messages">
<% flash.each do |key, value| %>
<div data-controller="removals"
data-removals-timeout-value="5000"
class="flex items-center <%= classes_for_flash(key) %> py-3 px-5 rounded-lg z-[6000]">
<div class="mr-4"><%= value %></div>
<button type="button" data-action="click->removals#remove">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
data-removals-timeout-value="<%= key.to_sym.in?([:notice, :success]) ? 5000 : 0 %>"
role="alert"
class="alert <%= flash_alert_class(key) %> shadow-lg z-[6000]">
<div class="flex items-center gap-2">
<%= flash_icon(key) %>
<span><%= value %></span>
</div>
<button type="button"
data-action="click->removals#remove"
class="btn btn-sm btn-circle btn-ghost"
aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>

View file

@ -8,6 +8,23 @@
<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? && DawarichSettings.family_feature_enabled? %>
<li>
<% if current_user.in_family? %>
<div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>">
<%= link_to family_path, class: "#{active_class?(family_path)} flex items-center space-x-2" do %>
<span>Family</span>
<div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div>
<% end %>
</div>
<% else %>
<%= link_to 'Family<sup>α</sup>'.html_safe, new_family_path, class: "#{active_class?(new_family_path)}" %>
<% end %>
</li>
<% end %>
<li>
<details>
<summary>My data</summary>
@ -56,6 +73,24 @@
<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? && DawarichSettings.family_feature_enabled? %>
<li>
<% if current_user.in_family? %>
<div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>"
class="<%= active_class?(family_path) %>">
<%= link_to family_path, class: "mx-1 flex items-center space-x-2" do %>
<span>Family<sup>α</sup></span>
<div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div>
<% end %>
</div>
<% else %>
<%= link_to 'Family<sup>α</sup>'.html_safe, new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
<% end %>
</li>
<% end %>
<li>
<details>
<summary>My data</summary>
@ -121,7 +156,8 @@
<li>
<details>
<summary>
<%= "#{current_user.email}" %>
<span class="hidden xl:inline"><%= current_user.email %></span>
<span class="inline xl:hidden"><%= icon 'user' %></span>
<% if onboarding_modal_showable?(current_user) %>
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>

View file

@ -38,17 +38,18 @@
<%= current_user.total_cities %>
</div>
<div class="stat-title">Cities visited</div>
<dialog id="cities_visited" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Cities visited</h3>
<p class="py-4">
<% current_user.cities_visited.each do |city| %>
<p><%= city %></p>
<% end %>
</p>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="cities_visited" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Cities visited</h3>
<p class="py-4">
<% current_user.cities_visited.each do |city| %>
<p><%= city %></p>
<% end %>
</p>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</div>

View file

@ -16,10 +16,9 @@
<div class="stat-title">Geopoints tracked</div>
</div>
<% if DawarichSettings.reverse_geocoding_enabled? %>
<%= render 'stats/reverse_geocoding_stats' %>
<% end %>
</div>
<% if DawarichSettings.reverse_geocoding_enabled? %>
<%= render 'stats/reverse_geocoding_stats' %>
<% end %>
<div class='text-xs text-gray-500 text-center mt-5'>
All stats data above except for total distance and number of geopoints tracked is being updated daily

View file

@ -23,5 +23,6 @@ pin 'leaflet-draw' # @1.0.4
pin 'notifications_channel', to: 'channels/notifications_channel.js'
pin 'points_channel', to: 'channels/points_channel.js'
pin 'imports_channel', to: 'channels/imports_channel.js'
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
pin 'trix'
pin '@rails/actiontext', to: 'actiontext.esm.js'

View file

@ -39,9 +39,14 @@ class DawarichSettings
@store_geodata ||= STORE_GEODATA
end
def family_feature_enabled?
@family_feature_enabled ||= self_hosted?
end
def features
@features ||= {
reverse_geocoding: reverse_geocoding_enabled?
reverse_geocoding: reverse_geocoding_enabled?,
family: family_feature_enabled?
}
end
end

View file

@ -56,6 +56,20 @@ Rails.application.routes.draw do
resources :places, only: %i[index destroy]
resources :exports, only: %i[index create destroy]
resources :trips
# Family management routes (only if feature is enabled)
if DawarichSettings.family_feature_enabled?
resource :family, only: %i[show new create edit update destroy] do
patch :update_location_sharing, on: :member
resources :invitations, except: %i[edit update], controller: 'family/invitations'
resources :members, only: %i[destroy], controller: 'family/memberships'
end
get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation
post 'family/memberships', to: 'family/memberships#create', as: :accept_family_invitation
end
resources :points, only: %i[index] do
collection do
delete :bulk_destroy
@ -87,15 +101,10 @@ Rails.application.routes.draw do
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
if SELF_HOSTED
devise_for :users, skip: [:registrations]
as :user do
get 'users/edit' => 'devise/registrations#edit', :as => 'edit_user_registration'
put 'users' => 'devise/registrations#update', :as => 'user_registration'
end
else
devise_for :users
end
devise_for :users, controllers: {
registrations: 'users/registrations',
sessions: 'users/sessions'
}
resources :metrics, only: [:index]
@ -157,6 +166,12 @@ Rails.application.routes.draw do
end
end
resources :families, only: [] do
collection do
get :locations
end
end
post 'subscriptions/callback', to: 'subscriptions#callback'
end
end

View file

@ -44,3 +44,8 @@ nightly_reverse_geocoding_job:
cron: "15 1 * * *" # every day at 01:15
class: "Points::NightlyReverseGeocodingJob"
queue: reverse_geocoding
nightly_family_invitations_cleanup_job:
cron: "30 2 * * *" # every day at 02:30
class: "Family::Invitations::CleanupJob"
queue: family

View file

@ -5,6 +5,7 @@
- points
- default
- mailers
- families
- imports
- exports
- stats

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateFamilies < ActiveRecord::Migration[8.0]
def change
create_table :families do |t|
t.string :name, null: false, limit: 50
t.bigint :creator_id, null: false
t.timestamps
end
add_foreign_key :families, :users, column: :creator_id, validate: false
add_index :families, :creator_id
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateFamilyMemberships < ActiveRecord::Migration[8.0]
def change
create_table :family_memberships do |t|
t.bigint :family_id, null: false
t.bigint :user_id, null: false
t.integer :role, null: false, default: 1 # member
t.timestamps
end
add_foreign_key :family_memberships, :families, validate: false
add_foreign_key :family_memberships, :users, validate: false
add_index :family_memberships, :user_id, unique: true # One family per user
add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role'
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class CreateFamilyInvitations < ActiveRecord::Migration[8.0]
def change
create_table :family_invitations do |t|
t.bigint :family_id, null: false
t.string :email, null: false
t.string :token, null: false
t.datetime :expires_at, null: false
t.bigint :invited_by_id, null: false
t.integer :status, null: false, default: 0 # pending
t.timestamps
end
add_foreign_key :family_invitations, :families, validate: false
add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false
add_index :family_invitations, :token, unique: true
add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email'
add_index :family_invitations, %i[family_id status expires_at],
name: 'index_family_invitations_on_family_status_expires'
add_index :family_invitations, %i[status expires_at],
name: 'index_family_invitations_on_status_and_expires_at'
add_index :family_invitations, %i[status updated_at],
name: 'index_family_invitations_on_status_and_updated_at'
end
end

View file

@ -0,0 +1,9 @@
class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0]
def change
validate_foreign_key :families, :users
validate_foreign_key :family_memberships, :families
validate_foreign_key :family_memberships, :users
validate_foreign_key :family_invitations, :families
validate_foreign_key :family_invitations, :users
end
end

View file

@ -0,0 +1,38 @@
# 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
# 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
# Cleanup job support for status and updated_at
unless index_exists?(:family_invitations, %i[status updated_at],
name: 'index_family_invitations_on_status_and_updated_at')
add_index :family_invitations, %i[status updated_at],
name: 'index_family_invitations_on_status_and_updated_at',
algorithm: :concurrently
end
end
end

42
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_18_215512) do
ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -96,6 +96,41 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
t.index ["user_id"], name: "index_exports_on_user_id"
end
create_table "families", force: :cascade do |t|
t.string "name", limit: 50, null: false
t.bigint "creator_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_families_on_creator_id"
end
create_table "family_invitations", force: :cascade do |t|
t.bigint "family_id", null: false
t.string "email", null: false
t.string "token", null: false
t.datetime "expires_at", null: false
t.bigint "invited_by_id", null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
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"], name: "index_family_invitations_on_family_id"
t.index ["status"], name: "index_family_invitations_on_status"
t.index ["token"], name: "index_family_invitations_on_token", unique: true
end
create_table "family_memberships", force: :cascade do |t|
t.bigint "family_id", null: false
t.bigint "user_id", null: false
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_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
end
create_table "imports", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id", null: false
@ -307,6 +342,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "areas", "users"
add_foreign_key "families", "users", column: "creator_id", validate: false
add_foreign_key "family_invitations", "families", validate: false
add_foreign_key "family_invitations", "users", column: "invited_by_id", validate: false
add_foreign_key "family_memberships", "families", validate: false
add_foreign_key "family_memberships", "users", validate: false
add_foreign_key "notifications", "users"
add_foreign_key "place_visits", "places"
add_foreign_key "place_visits", "visits"

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :family do
sequence(:name) { |n| "Test Family #{n}" }
association :creator, factory: :user
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
FactoryBot.define do
factory :family_invitation, class: 'Family::Invitation' do
association :family
association :invited_by, factory: :user
sequence(:email) { |n| "invite#{n}@example.com" }
token { SecureRandom.urlsafe_base64(32) }
expires_at { 7.days.from_now }
status { :pending }
trait :accepted do
status { :accepted }
end
trait :expired do
status { :expired }
expires_at { 1.day.ago }
end
trait :cancelled do
status { :cancelled }
end
trait :with_expired_date do
expires_at { 1.day.ago }
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
factory :family_membership, class: 'Family::Membership' do
association :family
association :user
role { :member }
trait :owner do
role { :owner }
end
end
end

View file

@ -0,0 +1,177 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Family::Invitation, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:invited_by).class_name('User') }
end
describe 'validations' do
subject { build(:family_invitation) }
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to allow_value('test@example.com').for(:email) }
it { is_expected.not_to allow_value('invalid-email').for(:email) }
it { is_expected.to validate_uniqueness_of(:token) }
it { is_expected.to validate_presence_of(:status) }
it 'validates token presence after creation' do
invitation = build(:family_invitation, token: nil)
invitation.save
expect(invitation.token).to be_present
end
it 'validates expires_at presence after creation' do
invitation = build(:family_invitation, expires_at: nil)
invitation.save
expect(invitation.expires_at).to be_present
end
end
describe 'enums' do
it { is_expected.to define_enum_for(:status).with_values(pending: 0, accepted: 1, expired: 2, cancelled: 3) }
end
describe 'scopes' do
let(:family) { create(:family) }
let(:pending_invitation) do
create(:family_invitation, family: family, status: :pending, expires_at: 1.day.from_now)
end
let(:expired_invitation) { create(:family_invitation, family: family, status: :pending, expires_at: 1.day.ago) }
let(:accepted_invitation) { create(:family_invitation, :accepted, family: family) }
describe '.active' do
it 'returns only pending and non-expired invitations' do
expect(Family::Invitation.active).to include(pending_invitation)
expect(Family::Invitation.active).not_to include(expired_invitation)
expect(Family::Invitation.active).not_to include(accepted_invitation)
end
end
end
describe 'callbacks' do
describe 'before_validation on create' do
let(:invitation) { build(:family_invitation, token: nil, expires_at: nil) }
it 'generates a token' do
invitation.save
expect(invitation.token).to be_present
expect(invitation.token.length).to be > 20
end
it 'sets expiry date' do
invitation.save
expect(invitation.expires_at).to be_within(1.minute).of(Family::Invitation::EXPIRY_DAYS.days.from_now)
end
it 'does not override existing token' do
custom_token = 'custom-token'
invitation.token = custom_token
invitation.save
expect(invitation.token).to eq(custom_token)
end
it 'does not override existing expiry date' do
custom_expiry = 3.days.from_now
invitation.expires_at = custom_expiry
invitation.save
expect(invitation.expires_at).to be_within(1.second).of(custom_expiry)
end
end
end
describe '#expired?' do
context 'when expires_at is in the future' do
let(:invitation) { create(:family_invitation, expires_at: 1.day.from_now) }
it 'returns false' do
expect(invitation.expired?).to be false
end
end
context 'when expires_at is in the past' do
let(:invitation) { create(:family_invitation, expires_at: 1.day.ago) }
it 'returns true' do
expect(invitation.expired?).to be true
end
end
end
describe '#can_be_accepted?' do
context 'when invitation is pending and not expired' do
let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.from_now) }
it 'returns true' do
expect(invitation.can_be_accepted?).to be true
end
end
context 'when invitation is pending but expired' do
let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.ago) }
it 'returns false' do
expect(invitation.can_be_accepted?).to be false
end
end
context 'when invitation is accepted' do
let(:invitation) { create(:family_invitation, :accepted, expires_at: 1.day.from_now) }
it 'returns false' do
expect(invitation.can_be_accepted?).to be false
end
end
context 'when invitation is cancelled' do
let(:invitation) { create(:family_invitation, :cancelled, expires_at: 1.day.from_now) }
it 'returns false' do
expect(invitation.can_be_accepted?).to be false
end
end
end
describe 'constants' do
it 'defines EXPIRY_DAYS' do
expect(Family::Invitation::EXPIRY_DAYS).to eq(7)
end
end
describe 'token uniqueness' do
let(:family) { create(:family) }
let(:user) { create(:user) }
it 'ensures each invitation has a unique token' do
invitation1 = create(:family_invitation, family: family, invited_by: user)
invitation2 = create(:family_invitation, family: family, invited_by: user)
expect(invitation1.token).not_to eq(invitation2.token)
end
end
describe 'email format validation' do
let(:invitation) { build(:family_invitation) }
it 'accepts valid email formats' do
valid_emails = ['test@example.com', 'user.name@domain.co.uk', 'user+tag@example.org']
valid_emails.each do |email|
invitation.email = email
expect(invitation).to be_valid
end
end
it 'rejects invalid email formats' do
invalid_emails = ['invalid-email', '@example.com', 'user@', 'user space@example.com']
invalid_emails.each do |email|
invitation.email = email
expect(invitation).not_to be_valid
expect(invitation.errors[:email]).to be_present
end
end
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Family::Membership, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
subject { build(:family_membership) }
it { is_expected.to validate_presence_of(:user_id) }
it { is_expected.to validate_uniqueness_of(:user_id) }
it { is_expected.to validate_presence_of(:role) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:role).with_values(owner: 0, member: 1) }
end
describe 'one family per user constraint' do
let(:user) { create(:user) }
let(:family1) { create(:family) }
let(:family2) { create(:family) }
it 'allows a user to be in one family' do
membership1 = build(:family_membership, family: family1, user: user)
expect(membership1).to be_valid
end
it 'prevents a user from being in multiple families' do
create(:family_membership, family: family1, user: user)
membership2 = build(:family_membership, family: family2, user: user)
expect(membership2).not_to be_valid
expect(membership2.errors[:user_id]).to include('has already been taken')
end
end
describe 'role assignment' do
let(:family) { create(:family) }
context 'when created as owner' do
let(:membership) { create(:family_membership, :owner, family: family) }
it 'can be created' do
expect(membership.role).to eq('owner')
expect(membership.owner?).to be true
end
end
context 'when created as member' do
let(:membership) { create(:family_membership, family: family, role: :member) }
it 'can be created' do
expect(membership.role).to eq('member')
expect(membership.member?).to be true
end
end
it 'defaults to member role' do
membership = create(:family_membership, family: family)
expect(membership.role).to eq('member')
end
end
end

125
spec/models/family_spec.rb Normal file
View file

@ -0,0 +1,125 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Family, type: :model do
let(:user) { create(:user) }
describe 'associations' do
it { is_expected.to have_many(:family_memberships).dependent(:destroy) }
it { is_expected.to have_many(:members).through(:family_memberships).source(:user) }
it { is_expected.to have_many(:family_invitations).dependent(:destroy) }
it { is_expected.to belong_to(:creator).class_name('User') }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(50) }
end
describe 'constants' do
it 'defines MAX_MEMBERS' do
expect(Family::MAX_MEMBERS).to eq(5)
end
end
describe '#can_add_members?' do
let(:family) { create(:family, creator: user) }
context 'when family has fewer than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
end
it 'returns true' do
expect(family.can_add_members?).to be true
end
end
context 'when family has max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
end
it 'returns false' do
expect(family.can_add_members?).to be false
end
end
context 'when family has no members' do
it 'returns true' do
expect(family.can_add_members?).to be true
end
end
end
describe 'family creation' do
let(:family) { Family.new(name: 'Test Family', creator: user) }
it 'can be created with valid attributes' do
expect(family).to be_valid
end
it 'requires a name' do
family.name = nil
expect(family).not_to be_valid
expect(family.errors[:name]).to include("can't be blank")
end
it 'requires a creator' do
family.creator = nil
expect(family).not_to be_valid
end
it 'rejects names longer than 50 characters' do
long_name = 'a' * 51
family.name = long_name
expect(family).not_to be_valid
expect(family.errors[:name]).to include('is too long (maximum is 50 characters)')
end
end
describe 'members association' do
let(:family) { create(:family, creator: user) }
let(:member1) { create(:user) }
let(:member2) { create(:user) }
before do
create(:family_membership, family: family, user: user, role: :owner)
create(:family_membership, family: family, user: member1, role: :member)
create(:family_membership, family: family, user: member2, role: :member)
end
it 'includes all family members' do
expect(family.members).to include(user, member1, member2)
expect(family.members.count).to eq(3)
end
end
describe 'family invitations association' do
let(:family) { create(:family, creator: user) }
it 'destroys associated invitations when family is destroyed' do
invitation = create(:family_invitation, family: family, invited_by: user)
expect { family.destroy }.to change(Family::Invitation, :count).by(-1)
expect(Family::Invitation.find_by(id: invitation.id)).to be_nil
end
end
describe 'family memberships association' do
let(:family) { create(:family, creator: user) }
it 'destroys associated memberships when family is destroyed' do
membership = create(:family_membership, family: family, user: user, role: :owner)
expect { family.destroy }.to change(Family::Membership, :count).by(-1)
expect(Family::Membership.find_by(id: membership.id)).to be_nil
end
end
end

View file

@ -0,0 +1,136 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe User, 'family methods', type: :model do
let(:user) { create(:user) }
describe 'family associations' do
it { is_expected.to have_one(:family_membership).dependent(:destroy) }
it { is_expected.to have_one(:family).through(:family_membership) }
it {
is_expected.to have_one(:created_family).class_name('Family').with_foreign_key('creator_id').dependent(:destroy)
}
it {
is_expected.to have_many(:sent_family_invitations).class_name('Family::Invitation').with_foreign_key('invited_by_id').dependent(:destroy)
}
end
describe '#in_family?' do
context 'when user has no family membership' do
it 'returns false' do
expect(user.in_family?).to be false
end
end
context 'when user has family membership' do
let(:family) { create(:family, creator: user) }
before do
create(:family_membership, user: user, family: family)
end
it 'returns true' do
expect(user.in_family?).to be true
end
end
end
describe '#family_owner?' do
let(:family) { create(:family, creator: user) }
context 'when user is family owner' do
before do
create(:family_membership, user: user, family: family, role: :owner)
end
it 'returns true' do
expect(user.family_owner?).to be true
end
end
context 'when user is family member' do
before do
create(:family_membership, user: user, family: family, role: :member)
end
it 'returns false' do
expect(user.family_owner?).to be false
end
end
context 'when user has no family membership' do
it 'returns false' do
expect(user.family_owner?).to be false
end
end
end
describe '#can_delete_account?' do
context 'when user is not a family owner' do
it 'returns true' do
expect(user.can_delete_account?).to be true
end
end
context 'when user is family owner with only themselves as member' do
let(:family) { create(:family, creator: user) }
before do
create(:family_membership, user: user, family: family, role: :owner)
end
it 'returns true' do
expect(user.can_delete_account?).to be true
end
end
context 'when user is family owner with other members' do
let(:family) { create(:family, creator: user) }
let(:other_user) { create(:user) }
before do
create(:family_membership, user: user, family: family, role: :owner)
create(:family_membership, user: other_user, family: family, role: :member)
end
it 'returns false' do
expect(user.can_delete_account?).to be false
end
end
end
describe 'dependent destroy behavior' do
let(:family) { create(:family, creator: user) }
context 'when user has created families' do
it 'prevents deletion when family has members' do
other_user = create(:user)
create(:family_membership, user: user, family: family, role: :owner)
create(:family_membership, user: other_user, family: family, role: :member)
expect(user.can_delete_account?).to be false
end
end
context 'when user has sent invitations' do
before do
create(:family_invitation, family: family, invited_by: user)
end
it 'destroys associated invitations when user is destroyed' do
expect { user.destroy }.to change(Family::Invitation, :count).by(-1)
end
end
context 'when user has family membership' do
before do
create(:family_membership, user: user, family: family)
end
it 'destroys associated membership when user is destroyed' do
expect { user.destroy }.to change(Family::Membership, :count).by(-1)
end
end
end
end

View file

@ -0,0 +1,238 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Family::InvitationPolicy, type: :policy do
let(:family) { create(:family) }
let(:owner) { family.creator }
let(:member) { create(:user) }
let(:other_user) { create(:user) }
let(:invitation) { create(:family_invitation, family: family, invited_by: owner) }
before do
create(:family_membership, family: family, user: owner, role: :owner)
create(:family_membership, family: family, user: member, role: :member)
end
describe '#create?' do
context 'when user is family owner' do
before do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
end
it 'allows family owner to create invitations' do
policy = described_class.new(owner, invitation)
expect(policy).to permit(:create)
end
end
context 'when user is regular family member' do
before do
allow(member).to receive(:family).and_return(family)
allow(member).to receive(:family_owner?).and_return(false)
end
it 'denies regular family member from creating invitations' do
policy = described_class.new(member, invitation)
expect(policy).not_to permit(:create)
end
end
context 'when user is not in the family' do
it 'denies user not in the family from creating invitations' do
policy = described_class.new(other_user, invitation)
expect(policy).not_to permit(:create)
end
end
context 'with unauthenticated user' do
it 'denies unauthenticated user from creating invitations' do
policy = described_class.new(nil, invitation)
expect(policy).not_to permit(:create)
end
end
end
describe '#accept?' do
context 'when user email matches invitation email' do
let(:invited_user) { create(:user, email: invitation.email) }
it 'allows user to accept invitation sent to their email' do
policy = described_class.new(invited_user, invitation)
expect(policy).to permit(:accept)
end
end
context 'when user email does not match invitation email' do
it 'denies user with different email from accepting invitation' do
policy = described_class.new(other_user, invitation)
expect(policy).not_to permit(:accept)
end
end
context 'when family owner tries to accept invitation' do
it 'denies family owner from accepting invitation sent to different email' do
policy = described_class.new(owner, invitation)
expect(policy).not_to permit(:accept)
end
end
context 'with unauthenticated user' do
it 'denies unauthenticated user from accepting invitation' do
policy = described_class.new(nil, invitation)
expect(policy).not_to permit(:accept)
end
end
end
describe '#destroy?' do
context 'when user is family owner' do
before do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
end
it 'allows family owner to cancel invitations' do
policy = described_class.new(owner, invitation)
expect(policy).to permit(:destroy)
end
end
context 'when user is regular family member' do
before do
allow(member).to receive(:family).and_return(family)
allow(member).to receive(:family_owner?).and_return(false)
end
it 'denies regular family member from cancelling invitations' do
policy = described_class.new(member, invitation)
expect(policy).not_to permit(:destroy)
end
end
context 'when user is not in the family' do
it 'denies user not in the family from cancelling invitations' do
policy = described_class.new(other_user, invitation)
expect(policy).not_to permit(:destroy)
end
end
context 'with unauthenticated user' do
it 'denies unauthenticated user from cancelling invitations' do
policy = described_class.new(nil, invitation)
expect(policy).not_to permit(:destroy)
end
end
end
describe 'edge cases' do
context 'when invitation belongs to different family' do
let(:other_family) { create(:family) }
let(:other_family_owner) { other_family.creator }
let(:other_invitation) { create(:family_invitation, family: other_family, invited_by: other_family_owner) }
before do
create(:family_membership, family: other_family, user: other_family_owner, role: :owner)
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
end
it 'denies owner from creating invitations for different family' do
policy = described_class.new(owner, other_invitation)
expect(policy).not_to permit(:create)
end
it 'denies owner from destroying invitations for different family' do
policy = described_class.new(owner, other_invitation)
expect(policy).not_to permit(:destroy)
end
end
context 'with expired invitation' do
let(:expired_invitation) { create(:family_invitation, :expired, family: family, invited_by: owner) }
let(:invited_user) { create(:user, email: expired_invitation.email) }
it 'still allows user to attempt to accept expired invitation (business logic handles expiry)' do
policy = described_class.new(invited_user, expired_invitation)
expect(policy).to permit(:accept)
end
it 'allows owner to destroy expired invitation' do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
policy = described_class.new(owner, expired_invitation)
expect(policy).to permit(:destroy)
end
end
context 'with accepted invitation' do
let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, invited_by: owner) }
it 'allows owner to destroy accepted invitation' do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
policy = described_class.new(owner, accepted_invitation)
expect(policy).to permit(:destroy)
end
end
context 'with cancelled invitation' do
let(:cancelled_invitation) { create(:family_invitation, :cancelled, family: family, invited_by: owner) }
it 'allows owner to destroy cancelled invitation' do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
policy = described_class.new(owner, cancelled_invitation)
expect(policy).to permit(:destroy)
end
end
end
describe 'authorization consistency' do
it 'ensures owner can both create and destroy invitations' do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
policy = described_class.new(owner, invitation)
expect(policy).to permit(:create)
expect(policy).to permit(:destroy)
end
it 'ensures regular members cannot create or destroy invitations' do
allow(member).to receive(:family).and_return(family)
allow(member).to receive(:family_owner?).and_return(false)
policy = described_class.new(member, invitation)
expect(policy).not_to permit(:create)
expect(policy).not_to permit(:destroy)
end
it 'ensures invited users can only accept their own invitations' do
invited_user = create(:user, email: invitation.email)
policy = described_class.new(invited_user, invitation)
expect(policy).to permit(:accept)
expect(policy).not_to permit(:create)
expect(policy).not_to permit(:destroy)
end
end
end

View file

@ -0,0 +1,205 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Family::MembershipPolicy, type: :policy do
let(:family) { create(:family) }
let(:owner) { family.creator }
let(:member) { create(:user) }
let(:another_member) { create(:user) }
let(:other_user) { create(:user) }
let(:owner_membership) { create(:family_membership, :owner, family: family, user: owner) }
let(:member_membership) { create(:family_membership, family: family, user: member) }
let(:another_member_membership) { create(:family_membership, family: family, user: another_member) }
describe '#create?' do
let(:valid_invitation) { create(:family_invitation, family: family, email: member.email) }
let(:expired_invitation) { create(:family_invitation, family: family, email: member.email, expires_at: 1.day.ago) }
let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, email: member.email) }
let(:wrong_email_invitation) { create(:family_invitation, family: family, email: 'wrong@example.com') }
context 'when user has valid invitation' do
it 'allows user to create membership with valid pending invitation for their email' do
policy = described_class.new(member, valid_invitation)
expect(policy).to permit(:create)
end
end
context 'when invitation is expired' do
it 'denies user from creating membership with expired invitation' do
policy = described_class.new(member, expired_invitation)
expect(policy).not_to permit(:create)
end
end
context 'when invitation is already accepted' do
it 'denies user from creating membership with already accepted invitation' do
policy = described_class.new(member, accepted_invitation)
expect(policy).not_to permit(:create)
end
end
context 'when invitation is for different email' do
it 'denies user from creating membership with invitation for different email' do
policy = described_class.new(member, wrong_email_invitation)
expect(policy).not_to permit(:create)
end
end
context 'with unauthenticated user' do
it 'denies unauthenticated user from creating membership' do
policy = described_class.new(nil, valid_invitation)
expect(policy).not_to permit(:create)
end
end
end
describe '#destroy?' do
context 'when user is removing themselves' do
it 'allows user to remove their own membership (leave family)' do
allow(member).to receive(:family).and_return(family)
policy = described_class.new(member, member_membership)
expect(policy).to permit(:destroy)
end
it 'allows owner to remove their own membership' do
allow(owner).to receive(:family).and_return(family)
policy = described_class.new(owner, owner_membership)
expect(policy).to permit(:destroy)
end
end
context 'when user is family owner' do
before do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
end
it 'allows family owner to remove other members' do
policy = described_class.new(owner, member_membership)
expect(policy).to permit(:destroy)
end
it 'allows family owner to remove multiple members' do
policy1 = described_class.new(owner, member_membership)
policy2 = described_class.new(owner, another_member_membership)
expect(policy1).to permit(:destroy)
expect(policy2).to permit(:destroy)
end
end
context 'when user is regular family member' do
before do
allow(member).to receive(:family).and_return(family)
allow(member).to receive(:family_owner?).and_return(false)
end
it 'denies regular member from removing other members' do
policy = described_class.new(member, another_member_membership)
expect(policy).not_to permit(:destroy)
end
it 'denies regular member from removing owner' do
policy = described_class.new(member, owner_membership)
expect(policy).not_to permit(:destroy)
end
end
context 'when user is not in the family' do
it 'denies user from removing membership of different family' do
policy = described_class.new(other_user, member_membership)
expect(policy).not_to permit(:destroy)
end
end
context 'with unauthenticated user' do
it 'denies unauthenticated user from removing membership' do
policy = described_class.new(nil, member_membership)
expect(policy).not_to permit(:destroy)
end
end
end
describe 'edge cases' do
context 'when membership belongs to different family' do
let(:other_family) { create(:family) }
let(:other_family_owner) { other_family.creator }
let(:other_family_membership) do
create(:family_membership, :owner, family: other_family, user: other_family_owner)
end
before do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
end
it 'denies owner from destroying membership of different family' do
policy = described_class.new(owner, other_family_membership)
expect(policy).not_to permit(:destroy)
end
end
context 'when owner tries to modify another owners membership' do
let(:co_owner) { create(:user) }
let(:co_owner_membership) { create(:family_membership, :owner, family: family, user: co_owner) }
before do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
end
it 'allows owner to remove another owner (family owner has full control)' do
policy = described_class.new(owner, co_owner_membership)
expect(policy).to permit(:destroy)
end
end
end
describe 'authorization consistency' do
it 'ensures owner can destroy all memberships in their family' do
allow(owner).to receive(:family).and_return(family)
allow(owner).to receive(:family_owner?).and_return(true)
policy = described_class.new(owner, member_membership)
expect(policy).to permit(:destroy)
end
it 'ensures regular members can only remove their own membership' do
allow(member).to receive(:family).and_return(family)
allow(member).to receive(:family_owner?).and_return(false)
own_policy = described_class.new(member, member_membership)
other_policy = described_class.new(member, another_member_membership)
# Can remove own membership
expect(own_policy).to permit(:destroy)
# Cannot remove others
expect(other_policy).not_to permit(:destroy)
end
it 'ensures users can always leave the family (remove own membership)' do
allow(member).to receive(:family).and_return(family)
policy = described_class.new(member, member_membership)
expect(policy).to permit(:destroy)
end
end
end

View file

@ -0,0 +1,252 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family', type: :request do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:family) { create(:family, creator: user) }
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in user
end
describe 'GET /family' do
it 'shows the family page' do
get "/family"
expect(response).to have_http_status(:ok)
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to new family path' do
get "/family"
expect(response).to redirect_to(new_family_path)
end
end
end
describe 'GET /family/new' do
context 'when user is not in a family' do
let(:user_without_family) { create(:user) }
before { sign_in user_without_family }
it 'renders the new family form' do
get '/family/new'
expect(response).to have_http_status(:ok)
end
end
context 'when user is already in a family' do
it 'redirects to family show page' do
get '/family/new'
expect(response).to redirect_to(family_path)
end
end
end
describe 'POST /family' do
let(:user_without_family) { create(:user) }
before { sign_in user_without_family }
context 'with valid attributes' do
let(:valid_attributes) { { family: { name: 'Test Family' } } }
it 'creates a new family' do
expect do
post '/family', params: valid_attributes
end.to change(Family, :count).by(1)
end
it 'creates a family membership for the user' do
expect do
post '/family', params: valid_attributes
end.to change(Family::Membership, :count).by(1)
end
it 'redirects to the new family with success message' do
post '/family', params: valid_attributes
expect(response).to have_http_status(:found)
expect(response.location).to eq family_url
follow_redirect!
expect(response.body).to include('Family created successfully!')
end
end
context 'with invalid attributes' do
let(:invalid_attributes) { { family: { name: '' } } }
it 'does not create a family' do
expect do
post '/family', params: invalid_attributes
end.not_to change(Family, :count)
end
it 'renders the new template with errors' do
post '/family', params: invalid_attributes
expect(response).to have_http_status(:unprocessable_content)
end
end
end
describe 'GET /family/edit' do
it 'shows the edit form' do
get "/family/edit"
expect(response).to have_http_status(:ok)
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do
get "/family/edit"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
end
describe 'PATCH /family' do
let(:new_attributes) { { family: { name: 'Updated Family Name' } } }
context 'with valid attributes' do
it 'updates the family' do
patch "/family", params: new_attributes
family.reload
expect(family.name).to eq('Updated Family Name')
expect(response).to redirect_to(family_path)
end
end
context 'with invalid attributes' do
let(:invalid_attributes) { { family: { name: '' } } }
it 'does not update the family' do
original_name = family.name
patch "/family", params: invalid_attributes
family.reload
expect(family.name).to eq(original_name)
expect(response).to have_http_status(:unprocessable_content)
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do
patch "/family", params: new_attributes
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
end
describe 'DELETE /family' do
context 'when family has only one member' do
it 'deletes the family' do
expect { delete '/family' }.to change(Family, :count).by(-1)
expect(response).to redirect_to(new_family_path)
end
end
context 'when family has multiple members' do
before do
create(:family_membership, user: other_user, family: family, role: :member)
end
it 'does not delete the family' do
expect { delete "/family" }.not_to change(Family, :count)
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Cannot delete family with members')
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do
delete "/family"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
end
describe 'authorization for outsiders' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'denies access to show when user is not in family' do
get "/family"
expect(response).to redirect_to(new_family_path)
end
it 'redirects to family page when user is not in family for edit' do
get "/family/edit"
expect(response).to redirect_to(new_family_path)
end
it 'redirects to family page when user is not in family for update' do
patch "/family", params: { family: { name: 'Hacked' } }
expect(response).to redirect_to(new_family_path)
end
it 'redirects to family page when user is not in family for destroy' do
delete "/family"
expect(response).to redirect_to(new_family_path)
end
end
describe 'authentication required' do
before { sign_out user }
it 'redirects to login for index' do
get '/family'
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for show' do
get "/family"
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for new' do
get '/family/new'
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for create' do
post '/family', params: { family: { name: 'Test' } }
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for edit' do
get "/family/edit"
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for update' do
patch "/family", params: { family: { name: 'Test' } }
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for destroy' do
delete "/family"
expect(response).to redirect_to(new_user_session_path)
end
end
end

View file

@ -0,0 +1,240 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family::Invitations', type: :request do
let(:user) { create(:user) }
let(:family) { create(:family, creator: user) }
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
let(:invitation) { create(:family_invitation, family: family, invited_by: user) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'GET /family/invitations' do
before { sign_in user }
it 'shows pending invitations' do
invitation # create the invitation
get "/family/invitations"
expect(response).to have_http_status(:ok)
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
get "/family/invitations"
expect(response).to redirect_to(new_family_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
get "/family/invitations"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'GET /invitations/:token (public invitation view)' do
context 'when invitation is valid and pending' do
it 'shows the invitation without authentication' do
get "/invitations/#{invitation.token}"
expect(response).to have_http_status(:ok)
end
end
context 'when invitation is expired' do
before { invitation.update!(expires_at: 1.day.ago) }
it 'redirects with error message' do
get "/invitations/#{invitation.token}"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('This invitation has expired')
end
end
context 'when invitation is not pending' do
before { invitation.update!(status: :accepted) }
it 'redirects with error message' do
get "/invitations/#{invitation.token}"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('This invitation is no longer valid')
end
end
context 'when invitation does not exist' do
it 'returns not found' do
get '/invitations/invalid-token'
expect(response).to have_http_status(:not_found)
end
end
end
describe 'POST /family/invitations' do
before { sign_in user }
context 'with valid email' do
let(:valid_params) do
{ family_invitation: { email: 'newuser@example.com' } }
end
it 'creates a new invitation' do
expect do
post "/family/invitations", params: valid_params
end.to change(Family::Invitation, :count).by(1)
end
it 'redirects with success message' do
post "/family/invitations", params: valid_params
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Invitation sent successfully!')
end
end
context 'with duplicate email' do
let(:duplicate_params) do
{ family_invitation: { email: invitation.email } }
end
it 'does not create a duplicate invitation' do
invitation # create the existing invitation
expect do
post "/family/invitations", params: duplicate_params
end.not_to change(Family::Invitation, :count)
end
it 'redirects with error message' do
invitation # create the existing invitation
post "/family/invitations", params: duplicate_params
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Invitation already sent to this email')
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do
post "/family/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
post "/family/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to redirect_to(new_family_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
post "/family/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /family/invitations/:id' do
before { sign_in user }
it 'cancels the invitation' do
delete "/family/invitations/#{invitation.token}"
invitation.reload
expect(invitation.status).to eq('cancelled')
end
it 'redirects with success message' do
delete "/family/invitations/#{invitation.token}"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Invitation cancelled')
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do
delete "/family/invitations/#{invitation.token}"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
delete "/family/invitations/#{invitation.token}"
expect(response).to redirect_to(new_family_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
delete "/family/invitations/#{invitation.token}"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'invitation workflow integration' do
let(:invitee) { create(:user) }
it 'completes full invitation acceptance workflow' do
# 1. Owner creates invitation
sign_in user
post "/family/invitations", params: {
family_invitation: { email: invitee.email }
}
expect(response).to redirect_to(family_path)
created_invitation = Family::Invitation.last
expect(created_invitation.email).to eq(invitee.email)
# 2. Invitee views public invitation page
sign_out user
get "/invitations/#{created_invitation.token}"
expect(response).to have_http_status(:ok)
# 3. Invitee accepts invitation
sign_in invitee
post accept_family_invitation_path(token: created_invitation.token)
expect(response).to redirect_to(family_path)
# 4. Verify invitee is now in family
expect(invitee.reload.family).to eq(family)
expect(created_invitation.reload.status).to eq('accepted')
end
end
end

View file

@ -0,0 +1,248 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family::Memberships', type: :request do
let(:user) { create(:user) }
let(:family) { create(:family, creator: user) }
let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) }
let(:member_user) { create(:user) }
let!(:member_membership) { create(:family_membership, user: member_user, family: family, role: :member) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in user
end
describe 'POST /family/memberships' do
let(:invitee) { create(:user) }
let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) }
context 'with valid invitation and user' do
before { sign_in invitee }
it 'accepts the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.to change { invitee.reload.family }.from(nil).to(family)
end
it 'redirects with success message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Welcome to the family!')
end
it 'marks invitation as accepted' do
post accept_family_invitation_path(token: invitee_invitation.token)
invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted')
end
end
context 'when user is already in a family' do
let(:other_family) { create(:family) }
before do
create(:family_membership, user: invitee, family: other_family, role: :member)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family before joining a new one')
end
end
context 'when invitation is expired' do
before do
invitee_invitation.update!(expires_at: 1.day.ago)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('This invitation is no longer valid or has expired')
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /family/members/:id' do
context 'when removing a regular member' do
it 'removes the member from the family' do
expect do
delete "/family/members/#{member_membership.id}"
end.to change(Family::Membership, :count).by(-1)
end
it 'redirects with success message' do
member_email = member_user.email
delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include("#{member_email} has been removed from the family")
end
it 'removes the user from the family' do
delete "/family/members/#{member_membership.id}"
expect(member_user.reload.family).to be_nil
end
end
context 'when trying to remove the owner' do
it 'does not remove the owner' do
expect do
delete "/family/members/#{owner_membership.id}"
end.not_to change(Family::Membership, :count)
end
it 'redirects with error message explaining owners must delete family' do
delete "/family/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Family owners cannot remove their own membership. To leave the family, delete it instead.')
end
it 'prevents owner removal even when they are the only member' do
member_membership.destroy!
expect do
delete "/family/members/#{owner_membership.id}"
end.not_to change(Family::Membership, :count)
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Family owners cannot remove their own membership')
end
end
context 'when membership does not belong to the family' do
let(:other_family) { create(:family) }
let(:other_membership) { create(:family_membership, family: other_family) }
it 'returns not found' do
delete "/family/members/#{other_membership.id}"
expect(response).to have_http_status(:not_found)
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(new_family_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'authorization for different member roles' do
context 'when member tries to remove another member' do
before { sign_in member_user }
it 'returns forbidden' do
delete "/family/members/#{owner_membership.id}"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
end
describe 'member removal workflow' do
it 'removes member and updates family associations' do
# Verify initial state
expect(family.members).to include(user, member_user)
expect(member_user.family).to eq(family)
# Remove member
delete "/family/members/#{member_membership.id}"
# Verify removal
expect(response).to redirect_to(family_path)
expect(family.reload.members).to include(user)
expect(family.members).not_to include(member_user)
expect(member_user.reload.family).to be_nil
end
it 'prevents removing owner regardless of member count' do
# Verify initial state
expect(family.members.count).to eq(2)
expect(user.family_owner?).to be true
# Try to remove owner
delete "/family/members/#{owner_membership.id}"
# Verify prevention
expect(response).to redirect_to(family_path)
expect(family.reload.members).to include(user, member_user)
expect(user.reload.family).to eq(family)
end
it 'prevents removing owner even when they are the only member' do
# Remove other member first
member_membership.destroy!
# Verify only owner remains
expect(family.reload.members.count).to eq(1)
expect(family.members).to include(user)
# Try to remove owner - should be prevented
expect do
delete "/family/members/#{owner_membership.id}"
end.not_to change(Family::Membership, :count)
expect(response).to redirect_to(family_path)
expect(user.reload.family).to eq(family)
expect(family.reload).to be_present
end
it 'requires owners to use family deletion to leave the family' do
member_membership.destroy!
delete "/family/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path)
expect(flash[:alert]).to include('Family owners cannot remove their own membership')
delete "/family"
expect(response).to redirect_to(new_family_path)
expect(user.reload.family).to be_nil
end
end
end

View file

@ -0,0 +1,300 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family Workflows', type: :request do
let(:user1) { create(:user, email: 'alice@example.com') }
let(:user2) { create(:user, email: 'bob@example.com') }
let(:user3) { create(:user, email: 'charlie@example.com') }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'Complete family creation and management workflow' do
it 'allows creating a family, inviting members, and managing the family' do
# Step 1: User1 creates a family
sign_in user1
get '/family/new'
expect(response).to have_http_status(:ok)
post '/family', params: { family: { name: 'The Smith Family' } }
# The redirect should be to the newly created family
expect(response).to have_http_status(:found)
family = Family.find_by(name: 'The Smith Family')
expect(family).to be_present
expect(family.name).to eq('The Smith Family')
expect(family.creator).to eq(user1)
expect(user1.reload.family).to eq(family)
expect(user1.family_owner?).to be true
# Step 2: User1 invites User2
post "/family/invitations", params: {
family_invitation: { email: user2.email }
}
expect(response).to redirect_to(family_path)
invitation = family.family_invitations.find_by(email: user2.email)
expect(invitation).to be_present
expect(invitation.email).to eq(user2.email)
expect(invitation.family).to eq(family)
expect(invitation.pending?).to be true
# Step 3: User2 views and accepts invitation
sign_out user1
# Public invitation view (no auth required)
get "/invitations/#{invitation.token}"
expect(response).to have_http_status(:ok)
# User2 accepts invitation
sign_in user2
post accept_family_invitation_path(token: invitation.token)
expect(response).to redirect_to(family_path)
expect(user2.reload.family).to eq(family)
expect(user2.family_owner?).to be false
expect(invitation.reload.accepted?).to be true
# Step 4: User1 invites User3
sign_in user1
post "/family/invitations", params: {
family_invitation: { email: user3.email }
}
invitation2 = family.family_invitations.find_by(email: user3.email)
expect(invitation2).to be_present
expect(invitation2.email).to eq(user3.email)
# Step 5: User3 accepts invitation
sign_in user3
post accept_family_invitation_path(token: invitation2.token)
expect(user3.reload.family).to eq(family)
expect(family.reload.members.count).to eq(3)
# Step 6: Family owner views members on family show page
sign_in user1
get "/family"
expect(response).to have_http_status(:ok)
# Step 7: Owner removes a member
delete "/family/members/#{user2.family_membership.id}"
expect(response).to redirect_to(family_path)
expect(user2.reload.family).to be_nil
expect(family.reload.members.count).to eq(2)
expect(family.members).to include(user1, user3)
expect(family.members).not_to include(user2)
end
end
describe 'Family invitation expiration workflow' do
let(:family) { create(:family, name: 'Test Family', creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
let!(:invitation) do
create(:family_invitation, family: family, email: user2.email, invited_by: user1, expires_at: 1.day.ago)
end
it 'handles expired invitations correctly' do
# User2 tries to view expired invitation
get "/invitations/#{invitation.token}"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('This invitation has expired')
# User2 tries to accept expired invitation
sign_in user2
post accept_family_invitation_path(token: invitation.token)
expect(response).to redirect_to(root_path)
expect(user2.reload.family).to be_nil
expect(invitation.reload.pending?).to be true
end
end
describe 'Multiple family membership prevention workflow' do
let(:family1) { create(:family, name: 'Family 1', creator: user1) }
let(:family2) { create(:family, name: 'Family 2', creator: user2) }
let!(:user1_membership) { create(:family_membership, user: user1, family: family1, role: :owner) }
let!(:user2_membership) { create(:family_membership, user: user2, family: family2, role: :owner) }
let!(:invitation1) { create(:family_invitation, family: family1, email: user3.email, invited_by: user1) }
let!(:invitation2) { create(:family_invitation, family: family2, email: user3.email, invited_by: user2) }
it 'prevents users from joining multiple families' do
# User3 accepts invitation to Family 1
sign_in user3
post accept_family_invitation_path(token: invitation1.token)
expect(response).to redirect_to(family_path)
expect(user3.family).to eq(family1)
# User3 tries to accept invitation to Family 2
post accept_family_invitation_path(token: invitation2.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family')
expect(user3.reload.family).to eq(family1) # Still in first family
end
end
describe 'Family ownership transfer and leaving workflow' do
let(:family) { create(:family, creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) }
it 'prevents owner from leaving when members exist' do
sign_in user1
# Owner tries to leave family with members (using memberships destroy route)
owner_membership = user1.family_membership
delete "/family/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('cannot remove their own membership')
expect(user1.reload.family).to eq(family)
expect(user1.family_owner?).to be true
end
it 'allows owner to leave when they are the only member' do
sign_in user1
# Remove the member first
delete "/family/members/#{member_membership.id}"
# Owner cannot leave even when alone - they must delete the family instead
owner_membership = user1.reload.family_membership
delete "/family/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('cannot remove their own membership')
expect(user1.reload.family).to eq(family)
end
it 'allows members to leave freely' do
sign_in user2
delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(new_family_path)
expect(user2.reload.family).to be_nil
expect(family.reload.members.count).to eq(1)
expect(family.members).to include(user1)
expect(family.members).not_to include(user2)
end
end
describe 'Family deletion workflow' do
let(:family) { create(:family, creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
context 'when members exist' do
let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) }
it 'prevents family deletion when members exist' do
sign_in user1
expect do
delete "/family"
end.not_to change(Family, :count)
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Cannot delete family with members')
end
end
it 'allows family deletion when owner is the only member' do
sign_in user1
expect do
delete "/family"
end.to change(Family, :count).by(-1)
expect(response).to redirect_to(new_family_path)
expect(user1.reload.family).to be_nil
end
end
describe 'Authorization workflow' do
let(:family) { create(:family, creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) }
it 'enforces proper authorization for family management' do
# Member cannot invite others
sign_in user2
post "/family/invitations", params: {
family_invitation: { email: user3.email }
}
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
# Member cannot remove other members
delete "/family/members/#{owner_membership.id}"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
# Member cannot edit family
patch "/family", params: { family: { name: 'Hacked Family' } }
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
# Member cannot delete family
delete "/family"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
# Outsider cannot access family
sign_in user3
get "/family"
expect(response).to redirect_to(new_family_path)
end
end
describe 'Email invitation workflow' do
let(:family) { create(:family, name: 'Test Family', creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
it 'handles invitation emails correctly' do
sign_in user1
# Mock email delivery
expect do
post "/family/invitations", params: {
family_invitation: { email: 'newuser@example.com' }
}
end.to change(Family::Invitation, :count).by(1)
invitation = family.family_invitations.find_by(email: 'newuser@example.com')
expect(invitation.email).to eq('newuser@example.com')
expect(invitation.token).to be_present
expect(invitation.expires_at).to be > Time.current
end
end
describe 'Navigation and redirect workflow' do
it 'handles proper redirects for family-related navigation' do
# User without family can access new family page
sign_in user1
get '/family/new'
expect(response).to have_http_status(:ok)
# User creates family
post '/family', params: { family: { name: 'Test Family' } }
expect(response).to have_http_status(:found)
# User with family can view their family
get '/family'
expect(response).to have_http_status(:ok)
# User with family gets redirected from new family page
get '/family/new'
expect(response).to redirect_to(family_path)
end
end
end

View file

@ -0,0 +1,323 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Users::Registrations', type: :request do
let(:family_owner) { create(:user) }
let(:family) { create(:family, creator: family_owner) }
let!(:owner_membership) { create(:family_membership, user: family_owner, family: family, role: :owner) }
let(:invitation) { create(:family_invitation, family: family, invited_by: family_owner, email: 'invited@example.com') }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'Family Invitation Registration Flow' do
context 'when accessing registration with a valid invitation token' do
it 'shows family-focused registration page' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Join #{family.name}!")
expect(response.body).to include(family_owner.email)
expect(response.body).to include(invitation.email)
expect(response.body).to include('Create Account &amp; Join Family')
end
it 'pre-fills email field with invitation email' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response.body).to include('value="invited@example.com"')
end
it 'makes email field readonly' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response.body).to include('readonly')
end
it 'hides normal login links' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response.body).not_to include('devise/shared/links')
end
end
context 'when accessing registration without invitation token' do
it 'shows normal registration page' do
get new_user_registration_path
expect(response).to have_http_status(:ok)
expect(response.body).to include('Register now!')
expect(response.body).to include('take control over your location data')
expect(response.body).not_to include('Join')
expect(response.body).to include('Sign up')
end
end
context 'when creating account with valid invitation token' do
let(:user_params) do
{
email: invitation.email,
password: 'password123',
password_confirmation: 'password123'
}
end
let(:request_params) do
{
user: user_params,
invitation_token: invitation.token
}
end
it 'creates user and accepts invitation automatically' do
expect do
post user_registration_path, params: request_params
end.to change(User, :count).by(1)
.and change { invitation.reload.status }.from('pending').to('accepted')
new_user = User.find_by(email: invitation.email)
expect(new_user).to be_present
expect(new_user.family).to eq(family)
expect(family.reload.members).to include(new_user)
end
it 'redirects to family page after successful registration' do
post user_registration_path, params: request_params
expect(response).to redirect_to(family_path)
end
it 'displays success message with family name' do
post user_registration_path, params: request_params
# Check that user got the default registration success message
# (family welcome message is set but may be overridden by Devise)
expect(flash[:notice]).to include("signed up successfully")
end
end
context 'when creating account with invalid invitation token' do
it 'creates user but does not accept any invitation' do
expect do
post user_registration_path, params: {
user: {
email: 'user@example.com',
password: 'password123',
password_confirmation: 'password123'
},
invitation_token: 'invalid-token'
}
end.to change(User, :count).by(1)
new_user = User.find_by(email: 'user@example.com')
expect(new_user.family).to be_nil
end
end
context 'when invitation email does not match registration email' do
it 'creates user but does not accept invitation' do
expect do
post user_registration_path, params: {
user: {
email: 'different@example.com',
password: 'password123',
password_confirmation: 'password123'
},
invitation_token: invitation.token
}
end.to change(User, :count).by(1)
new_user = User.find_by(email: 'different@example.com')
expect(new_user.family).to be_nil
expect(invitation.reload.status).to eq('pending')
end
end
end
describe 'Self-Hosted Mode' do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true')
end
context 'when accessing registration without invitation token' do
it 'redirects to root with error message' do
get new_user_registration_path
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('Registration is not available')
end
it 'prevents account creation' do
expect do
post user_registration_path, params: {
user: {
email: 'test@example.com',
password: 'password123',
password_confirmation: 'password123'
}
}
end.not_to change(User, :count)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('Registration is not available')
end
end
context 'when accessing registration with valid invitation token' do
it 'allows registration page access' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Join #{family.name}!")
end
it 'allows account creation' do
expect do
post user_registration_path, params: {
user: {
email: invitation.email,
password: 'password123',
password_confirmation: 'password123'
},
invitation_token: invitation.token
}
end.to change(User, :count).by(1)
expect(response).to redirect_to(family_path)
end
end
context 'when accessing registration with expired invitation' do
before { invitation.update!(expires_at: 1.day.ago) }
it 'redirects to root with error message' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('Registration is not available')
end
end
context 'when accessing registration with cancelled invitation' do
before { invitation.update!(status: :cancelled) }
it 'redirects to root with error message' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('Registration is not available')
end
end
end
describe 'Non-Self-Hosted Mode' do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('false')
end
context 'when accessing registration without invitation token' do
it 'allows normal registration' do
get new_user_registration_path
expect(response).to have_http_status(:ok)
expect(response.body).to include('Register now!')
end
it 'allows account creation' do
expect do
post user_registration_path, params: {
user: {
email: 'test@example.com',
password: 'password123',
password_confirmation: 'password123'
}
}
end.to change(User, :count).by(1)
expect(response).to redirect_to(root_path)
end
end
end
describe 'Invitation Token Handling' do
it 'accepts invitation token from params' do
get new_user_registration_path(invitation_token: invitation.token)
expect(response.body).to include("Join #{invitation.family.name}!")
end
it 'accepts invitation token from nested user params' do
post user_registration_path, params: {
user: {
email: invitation.email,
password: 'password123',
password_confirmation: 'password123'
},
invitation_token: invitation.token
}
new_user = User.find_by(email: invitation.email)
expect(new_user.family).to eq(family)
end
it 'handles session-stored invitation token' do
# Simulate session storage by passing the token directly in params
# (In real usage, this would come from the session after redirect from invitation page)
get new_user_registration_path(invitation_token: invitation.token)
expect(response.body).to include("Join #{invitation.family.name}!")
end
end
describe 'Error Handling' do
context 'when invitation acceptance fails' do
before do
# Mock service failure
allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_return(false)
allow_any_instance_of(Families::AcceptInvitation).to receive(:error_message).and_return('Mock error')
end
it 'creates user but shows invitation error in flash' do
expect do
post user_registration_path, params: {
user: {
email: invitation.email,
password: 'password123',
password_confirmation: 'password123'
},
invitation_token: invitation.token
}
end.to change(User, :count).by(1)
expect(flash[:alert]).to include('Mock error')
end
end
context 'when invitation acceptance raises exception' do
before do
# Mock service exception
allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_raise(StandardError, 'Test error')
end
it 'creates user but shows generic error in flash' do
expect do
post user_registration_path, params: {
user: {
email: invitation.email,
password: 'password123',
password_confirmation: 'password123'
},
invitation_token: invitation.token
}
end.to change(User, :count).by(1)
expect(flash[:alert]).to include('there was an issue accepting the invitation')
end
end
end
end

View file

@ -11,19 +11,21 @@ RSpec.describe 'Users', type: :request do
describe 'GET /users/sign_up' do
context 'when self-hosted' do
before do
stub_const('SELF_HOSTED', true)
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true')
end
it 'returns http success' do
it 'redirects to root path' do
get '/users/sign_up'
expect(response).to have_http_status(:not_found)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('Registration is not available')
end
end
context 'when not self-hosted' do
before do
stub_const('SELF_HOSTED', false)
Rails.application.reload_routes!
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return(nil)
end
it 'returns http success' do

Some files were not shown because too many files have changed in this diff Show more