|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/build_and_push.yml
vendored
|
|
@ -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
|
|
@ -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.
|
||||
21
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
1
Procfile
|
|
@ -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
|
||||
|
|
|
|||
5
app.json
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
1
app/assets/svg/icons/lucide/outline/chart-column.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/chevron-left.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/chevron-right.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/circle-alert.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/circle-check.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/circle-x.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/heart.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/shield-check.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/square-pen.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/trash-2.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/user.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/users.svg
Normal 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 |
20
app/channels/family_locations_channel.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyLocationsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
return reject unless family_feature_enabled?
|
||||
return reject unless current_user.in_family?
|
||||
|
||||
stream_for current_user.family
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Any cleanup needed when channel is unsubscribed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def family_feature_enabled?
|
||||
DawarichSettings.family_feature_enabled?
|
||||
end
|
||||
end
|
||||
24
app/controllers/api/v1/families_controller.rb
Normal 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
|
||||
|
|
@ -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,7 +75,7 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def user_not_authorized
|
||||
redirect_back fallback_location: root_path,
|
||||
redirect_to (request.referer || root_path),
|
||||
alert: 'You are not authorized to perform this action.',
|
||||
status: :see_other
|
||||
end
|
||||
|
|
|
|||
99
app/controllers/families_controller.rb
Normal 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
|
||||
76
app/controllers/family/invitations_controller.rb
Normal 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
|
||||
70
app/controllers/family/memberships_controller.rb
Normal 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
|
||||
93
app/controllers/users/registrations_controller.rb
Normal 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
|
||||
35
app/controllers/users/sessions_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
24
app/javascript/channels/family_locations_channel.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import consumer from "./consumer"
|
||||
|
||||
// Only create subscription if family feature is enabled
|
||||
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]');
|
||||
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {};
|
||||
|
||||
if (features.family) {
|
||||
consumer.subscriptions.create("FamilyLocationsChannel", {
|
||||
connected() {
|
||||
// Connected to family locations channel
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
// Disconnected from family locations channel
|
||||
},
|
||||
|
||||
received(data) {
|
||||
// Pass data to family members controller if it exists
|
||||
if (window.familyMembersController) {
|
||||
window.familyMembersController.updateSingleMemberLocation(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
import "notifications_channel"
|
||||
import "points_channel"
|
||||
import "imports_channel"
|
||||
import "family_locations_channel"
|
||||
|
|
|
|||
486
app/javascript/controllers/family_members_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
276
app/javascript/controllers/location_sharing_toggle_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
24
app/jobs/family/invitations/cleanup_job.rb
Normal 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
|
||||
25
app/mailers/family_mailer.rb
Normal 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
|
||||
116
app/models/concerns/user_family.rb
Normal 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
|
|
@ -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
|
||||
46
app/models/family/invitation.rb
Normal 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
|
||||
23
app/models/family/membership.rb
Normal 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
|
||||
|
|
@ -75,8 +75,7 @@ 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,
|
||||
[
|
||||
|
|
@ -91,8 +90,34 @@ class Point < ApplicationRecord
|
|||
]
|
||||
)
|
||||
end
|
||||
|
||||
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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
19
app/policies/family/invitation_policy.rb
Normal 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
|
||||
17
app/policies/family/membership_policy.rb
Normal 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
|
||||
22
app/policies/family_invitation_policy.rb
Normal 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
|
||||
23
app/policies/family_membership_policy.rb
Normal 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
|
||||
42
app/policies/family_policy.rb
Normal 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
|
||||
123
app/services/families/accept_invitation.rb
Normal 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
|
||||
126
app/services/families/create.rb
Normal 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
|
||||
125
app/services/families/invite.rb
Normal 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
|
||||
48
app/services/families/locations.rb
Normal 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
|
||||
157
app/services/families/memberships/destroy.rb
Normal 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
|
||||
68
app/services/families/update_location_sharing.rb
Normal 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
|
||||
|
|
@ -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,11 +60,14 @@
|
|||
<% 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>
|
||||
|
||||
<% unless @invitation %>
|
||||
<%= render "devise/shared/links" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,11 +45,13 @@
|
|||
<% 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>
|
||||
|
||||
<% unless @invitation %>
|
||||
<%= render "devise/shared/links" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
99
app/views/families/edit.html.erb
Normal 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>
|
||||
47
app/views/families/index.html.erb
Normal 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>
|
||||
66
app/views/families/new.html.erb
Normal 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>
|
||||
249
app/views/families/show.html.erb
Normal 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>
|
||||
57
app/views/family/invitations/index.html.erb
Normal 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>
|
||||
164
app/views/family/invitations/show.html.erb
Normal 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>
|
||||
48
app/views/family_mailer/invitation.html.erb
Normal 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>
|
||||
22
app/views/family_mailer/invitation.text.erb
Normal 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
|
||||
39
app/views/family_mailer/member_joined.html.erb
Normal 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>
|
||||
18
app/views/family_mailer/member_joined.text.erb
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -52,3 +52,4 @@
|
|||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<%= render 'stats/reverse_geocoding_stats' %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
- points
|
||||
- default
|
||||
- mailers
|
||||
- families
|
||||
- imports
|
||||
- exports
|
||||
- stats
|
||||
|
|
|
|||
14
db/migrate/20250926220114_create_families.rb
Normal 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
|
||||
17
db/migrate/20250926220135_create_family_memberships.rb
Normal 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
|
||||
26
db/migrate/20250926220158_create_family_invitations.rb
Normal 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
|
||||
|
|
@ -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
|
||||
38
db/migrate/20250928000001_add_family_performance_indexes.rb
Normal 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
|
|
@ -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"
|
||||
|
|
|
|||
8
spec/factories/families.rb
Normal 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
|
||||
29
spec/factories/family_invitations.rb
Normal 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
|
||||
13
spec/factories/family_memberships.rb
Normal 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
|
||||
177
spec/models/family/invitation_spec.rb
Normal 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
|
||||
68
spec/models/family/membership_spec.rb
Normal 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
|
|
@ -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
|
||||
136
spec/models/user_family_spec.rb
Normal 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
|
||||
238
spec/policies/family/invitation_policy_spec.rb
Normal 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
|
||||
205
spec/policies/family/membership_policy_spec.rb
Normal 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
|
||||
252
spec/requests/families_spec.rb
Normal 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
|
||||
240
spec/requests/family/invitations_spec.rb
Normal 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
|
||||
248
spec/requests/family/memberships_spec.rb
Normal 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
|
||||
300
spec/requests/family_workflows_spec.rb
Normal 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
|
||||
323
spec/requests/users/registrations_spec.rb
Normal 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 & 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||