From 0d02f08199f2c11ddab1f9eba4019e063be738aa Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 27 Sep 2025 00:46:29 +0200 Subject: [PATCH] Add implementation plan and complete phase 1 --- FAMILY_PLAN.md | 1650 +++++++++++++++++ app/models/family.rb | 16 + app/models/family_invitation.rb | 37 + app/models/family_membership.rb | 11 + app/models/user.rb | 54 + db/migrate/20250926220114_create_families.rb | 14 + ...0250926220135_create_family_memberships.rb | 18 + ...0250926220158_create_family_invitations.rb | 23 + ...0926220345_validate_family_foreign_keys.rb | 9 + db/schema.rb | 43 +- spec/factories/families.rb | 8 + spec/factories/family_invitations.rb | 29 + spec/factories/family_memberships.rb | 13 + spec/models/family_invitation_spec.rb | 177 ++ spec/models/family_membership_spec.rb | 68 + spec/models/family_spec.rb | 125 ++ spec/models/user_family_spec.rb | 136 ++ 17 files changed, 2430 insertions(+), 1 deletion(-) create mode 100644 FAMILY_PLAN.md create mode 100644 app/models/family.rb create mode 100644 app/models/family_invitation.rb create mode 100644 app/models/family_membership.rb create mode 100644 db/migrate/20250926220114_create_families.rb create mode 100644 db/migrate/20250926220135_create_family_memberships.rb create mode 100644 db/migrate/20250926220158_create_family_invitations.rb create mode 100644 db/migrate/20250926220345_validate_family_foreign_keys.rb create mode 100644 spec/factories/families.rb create mode 100644 spec/factories/family_invitations.rb create mode 100644 spec/factories/family_memberships.rb create mode 100644 spec/models/family_invitation_spec.rb create mode 100644 spec/models/family_membership_spec.rb create mode 100644 spec/models/family_spec.rb create mode 100644 spec/models/user_family_spec.rb diff --git a/FAMILY_PLAN.md b/FAMILY_PLAN.md new file mode 100644 index 00000000..eca9261c --- /dev/null +++ b/FAMILY_PLAN.md @@ -0,0 +1,1650 @@ +# Family Plan Feature - Implementation Specification + +## Implementation Status + +### โœ… Phase 1: Database Foundation - COMPLETED +- **3 Database tables created**: families, family_memberships, family_invitations +- **4 Model classes implemented**: Family, FamilyMembership, FamilyInvitation, User extensions +- **68 comprehensive tests written and passing**: Full test coverage for all models and associations +- **Database migrations applied**: All tables created with proper indexes and constraints +- **Business logic methods implemented**: User family ownership, account deletion protection, etc. + +**Ready for Phase 2**: Core Business Logic (Service Classes) + +--- + +## Overview + +The Family Plan feature allows Dawarich users to create family groups, invite members, and share their latest location data within the family. This feature enhances the social aspect of location tracking while maintaining strong privacy controls. + +### Key Features +- Create and manage family groups +- Invite members via email +- Share latest location data within family +- Role-based permissions (owner/member) +- Privacy controls for location sharing +- Email notifications and in-app notifications + +### Business Rules +- Maximum 5 family members per family (hardcoded constant) +- One family per user (must leave current family to join another) +- Family owners cannot delete their accounts +- Invitation tokens expire after 7 days +- Only latest position sharing (no historical data access) +- Free for self-hosted instances, paid feature for Dawarich Cloud + +## Database Schema + +### 1. Family Model +```ruby +class Family < ApplicationRecord + # Table: families + # Primary Key: id (UUID) + + self.primary_key = :id + + has_many :family_memberships, dependent: :destroy + has_many :members, through: :family_memberships, source: :user + has_many :family_invitations, dependent: :destroy + belongs_to :creator, class_name: 'User' + + validates :name, presence: true, length: { maximum: 50 } + validates :creator_id, presence: true + + MAX_MEMBERS = 5 +end +``` + +**Columns:** +- `id` (UUID, primary key) +- `name` (string, not null) +- `creator_id` (UUID, foreign key to users, not null) +- `created_at` (datetime) +- `updated_at` (datetime) + +### 2. FamilyMembership Model +```ruby +class FamilyMembership < ApplicationRecord + # Table: family_memberships + # Primary Key: id (UUID) + + self.primary_key = :id + + belongs_to :family + belongs_to :user + + validates :family_id, presence: true + validates :user_id, presence: true, uniqueness: true # One family per user + validates :role, presence: true + validates :status, presence: true + + enum role: { owner: 0, member: 1 } + enum status: { active: 0, inactive: 1 } + + scope :active, -> { where(status: :active) } +end +``` + +**Columns:** +- `id` (UUID, primary key) +- `family_id` (UUID, foreign key to families, not null) +- `user_id` (UUID, foreign key to users, not null, unique) +- `role` (integer, enum: owner=0, member=1, not null) +- `status` (integer, enum: active=0, inactive=1, not null, default: active) +- `location_sharing_enabled` (boolean, default: true) +- `created_at` (datetime) +- `updated_at` (datetime) + +### 3. FamilyInvitation Model +```ruby +class FamilyInvitation < ApplicationRecord + # Table: family_invitations + # Primary Key: id (UUID) + + self.primary_key = :id + + 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, presence: true + validates :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 + + EXPIRY_DAYS = 7 +end +``` + +**Columns:** +- `id` (UUID, primary key) +- `family_id` (UUID, foreign key to families, not null) +- `email` (string, not null) +- `token` (string, not null, unique) +- `expires_at` (datetime, not null) +- `invited_by_id` (UUID, foreign key to users, not null) +- `status` (integer, enum: pending=0, accepted=1, expired=2, cancelled=3, default: pending) +- `created_at` (datetime) +- `updated_at` (datetime) + +### 4. User Model Modifications +```ruby +# Add to existing User model +has_one :family_membership, dependent: :destroy +has_one :family, through: :family_membership +has_many :created_families, class_name: 'Family', foreign_key: 'creator_id', dependent: :restrict_with_error +has_many :sent_family_invitations, class_name: 'FamilyInvitation', foreign_key: 'invited_by_id', dependent: :destroy + +def in_family? + family_membership&.active? +end + +def family_owner? + family_membership&.owner? +end + +def can_delete_account? + return true unless family_owner? + family.members.count <= 1 +end +``` + +## Database Migrations + +### 1. Create Families Table +```ruby +class CreateFamilies < ActiveRecord::Migration[8.0] + def change + enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto') + + create_table :families, id: :uuid do |t| + t.string :name, null: false, limit: 50 + t.uuid :creator_id, null: false + t.timestamps + end + + add_foreign_key :families, :users, column: :creator_id + add_index :families, :creator_id + end +end +``` + +### 2. Create Family Memberships Table +```ruby +class CreateFamilyMemberships < ActiveRecord::Migration[8.0] + def change + create_table :family_memberships, id: :uuid do |t| + t.uuid :family_id, null: false + t.uuid :user_id, null: false + t.integer :role, null: false, default: 1 # member + t.integer :status, null: false, default: 0 # active + t.boolean :location_sharing_enabled, null: false, default: true + t.timestamps + end + + add_foreign_key :family_memberships, :families + add_foreign_key :family_memberships, :users + add_index :family_memberships, :family_id + add_index :family_memberships, :user_id, unique: true # One family per user + add_index :family_memberships, [:family_id, :role] + end +end +``` + +### 3. Create Family Invitations Table +```ruby +class CreateFamilyInvitations < ActiveRecord::Migration[8.0] + def change + create_table :family_invitations, id: :uuid do |t| + t.uuid :family_id, null: false + t.string :email, null: false + t.string :token, null: false + t.datetime :expires_at, null: false + t.uuid :invited_by_id, null: false + t.integer :status, null: false, default: 0 # pending + t.timestamps + end + + add_foreign_key :family_invitations, :families + add_foreign_key :family_invitations, :users, column: :invited_by_id + add_index :family_invitations, :family_id + add_index :family_invitations, :email + add_index :family_invitations, :token, unique: true + add_index :family_invitations, :status + add_index :family_invitations, :expires_at + end +end +``` + +## Service Classes + +### 1. Families::CreateService +```ruby +module Families + class CreateService + include ActiveModel::Validations + + attr_reader :user, :name, :family + + validates :name, presence: true, length: { maximum: 50 } + + def initialize(user:, name:) + @user = user + @name = name + end + + def call + return false unless valid? + return false if user.in_family? + return false unless can_create_family? + + ActiveRecord::Base.transaction do + create_family + create_owner_membership + send_notification + end + + true + rescue ActiveRecord::RecordInvalid + false + end + + private + + def can_create_family? + return true if DawarichSettings.self_hosted? + # Add cloud plan validation here + user.active? && user.active_until&.future? + end + + def create_family + @family = Family.create!( + name: name, + creator: user + ) + end + + def create_owner_membership + FamilyMembership.create!( + family: family, + user: user, + role: :owner, + status: :active + ) + end + + def send_notification + Notifications::Create.new( + user: user, + kind: :info, + title: 'Family Created', + content: "You've successfully created the family '#{family.name}'" + ).call + end + end +end +``` + +### 2. Families::InviteService +```ruby +module Families + class InviteService + 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 can_invite? + + ActiveRecord::Base.transaction do + create_invitation + send_invitation_email + send_notification + end + + true + rescue ActiveRecord::RecordInvalid + false + end + + private + + def can_invite? + return false unless invited_by.family_owner? + return false if family.members.count >= Family::MAX_MEMBERS + return false if user_already_in_family? + return false if pending_invitation_exists? + + true + end + + def user_already_in_family? + User.joins(:family_membership) + .where(email: email, family_memberships: { status: :active }) + .exists? + end + + def pending_invitation_exists? + family.family_invitations.active.where(email: email).exists? + end + + def create_invitation + @invitation = FamilyInvitation.create!( + family: family, + email: email, + invited_by: invited_by + ) + end + + def send_invitation_email + FamilyMailer.invitation(@invitation).deliver_later + end + + def send_notification + Notifications::Create.new( + user: invited_by, + kind: :info, + title: 'Invitation Sent', + content: "Family invitation sent to #{email}" + ).call + end + end +end +``` + +### 3. Families::AcceptInvitationService +```ruby +module Families + class AcceptInvitationService + attr_reader :invitation, :user + + def initialize(invitation:, user:) + @invitation = invitation + @user = user + end + + def call + return false unless can_accept? + + ActiveRecord::Base.transaction do + leave_current_family if user.in_family? + create_membership + update_invitation + send_notifications + end + + true + rescue ActiveRecord::RecordInvalid + false + end + + private + + def can_accept? + return false unless invitation.pending? + return false if invitation.expires_at < Time.current + return false unless invitation.email == user.email + return false if invitation.family.members.count >= Family::MAX_MEMBERS + + true + end + + def leave_current_family + Families::LeaveService.new(user: user).call + end + + def create_membership + FamilyMembership.create!( + family: invitation.family, + user: user, + role: :member, + status: :active + ) + end + + def update_invitation + invitation.update!(status: :accepted) + end + + def send_notifications + # Notify the user + Notifications::Create.new( + user: user, + kind: :info, + title: 'Welcome to Family', + content: "You've joined the family '#{invitation.family.name}'" + ).call + + # Notify family owner + Notifications::Create.new( + user: invitation.family.creator, + kind: :info, + title: 'New Family Member', + content: "#{user.email} has joined your family" + ).call + end + end +end +``` + +### 4. Families::LeaveService +```ruby +module Families + class LeaveService + attr_reader :user + + def initialize(user:) + @user = user + end + + def call + return false unless user.in_family? + return false if user.family_owner? && family_has_other_members? + + ActiveRecord::Base.transaction do + handle_ownership_transfer if user.family_owner? + deactivate_membership + send_notification + end + + true + end + + private + + def family_has_other_members? + user.family.members.count > 1 + end + + def handle_ownership_transfer + # If owner is leaving and no other members, family will be deleted via cascade + # If owner tries to leave with other members, it is_expected.to be prevented in controller + end + + def deactivate_membership + user.family_membership.update!(status: :inactive) + end + + def send_notification + Notifications::Create.new( + user: user, + kind: :info, + title: 'Left Family', + content: "You've left the family" + ).call + end + end +end +``` + +### 5. Families::LocationSharingService +```ruby +module Families + class LocationSharingService + def self.family_locations(family) + return [] unless family + + family.members + .joins(:family_membership) + .where(family_memberships: { location_sharing_enabled: true }) + .map { |member| latest_location_for(member) } + .compact + end + + def self.latest_location_for(user) + latest_point = user.points.order(timestamp: :desc).first + return nil unless latest_point + + { + user_id: user.id, + email: user.email, + latitude: latest_point.latitude, + longitude: latest_point.longitude, + timestamp: latest_point.timestamp, + updated_at: Time.at(latest_point.timestamp) + } + end + end +end +``` + +## Controllers + +### 1. FamiliesController +```ruby +class FamiliesController < ApplicationController + before_action :authenticate_user! + before_action :set_family, only: [:show, :edit, :update, :destroy, :leave] + + def index + redirect_to family_path(current_user.family) if current_user.in_family? + end + + def show + authorize @family + @members = @family.members.includes(:family_membership) + @pending_invitations = @family.family_invitations.pending + @family_locations = Families::LocationSharingService.family_locations(@family) + end + + def new + redirect_to family_path(current_user.family) if current_user.in_family? + @family = Family.new + end + + def create + service = Families::CreateService.new( + user: current_user, + name: family_params[:name] + ) + + if service.call + redirect_to family_path(service.family), notice: 'Family created successfully!' + else + @family = Family.new(family_params) + @family.errors.add(:base, 'Failed to create family') + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @family + end + + def update + authorize @family + + if @family.update(family_params) + redirect_to family_path(@family), notice: 'Family updated successfully!' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @family + + if @family.members.count > 1 + redirect_to family_path(@family), alert: 'Cannot delete family with members. Remove all members first.' + else + @family.destroy + redirect_to families_path, notice: 'Family deleted successfully!' + end + end + + def leave + authorize @family, :leave? + + service = Families::LeaveService.new(user: current_user) + + if service.call + redirect_to families_path, notice: 'You have left the family' + else + redirect_to family_path(@family), alert: 'Cannot leave family. Transfer ownership first.' + end + end + + private + + def set_family + @family = current_user.family + redirect_to families_path unless @family + end + + def family_params + params.require(:family).permit(:name) + end +end +``` + +### 2. FamilyMembershipsController +```ruby +class FamilyMembershipsController < ApplicationController + before_action :authenticate_user! + before_action :set_family + before_action :set_membership, only: [:show, :update, :destroy] + + def index + authorize @family, :show? + @members = @family.members.includes(:family_membership) + end + + def show + authorize @membership, :show? + end + + def update + authorize @membership + + if @membership.update(membership_params) + redirect_to family_path(@family), notice: 'Settings updated successfully!' + else + redirect_to family_path(@family), alert: 'Failed to update settings' + end + end + + def destroy + authorize @membership + + if @membership.owner? && @family.members.count > 1 + redirect_to family_path(@family), alert: 'Transfer ownership before removing yourself' + else + @membership.update!(status: :inactive) + redirect_to family_path(@family), notice: 'Member removed successfully' + end + end + + private + + def set_family + @family = current_user.family + redirect_to families_path unless @family + end + + def set_membership + @membership = @family.family_memberships.find(params[:id]) + end + + def membership_params + params.require(:family_membership).permit(:location_sharing_enabled) + end +end +``` + +### 3. FamilyInvitationsController +```ruby +class FamilyInvitationsController < ApplicationController + before_action :authenticate_user!, except: [:show, :accept] + before_action :set_family, except: [:show, :accept] + before_action :set_invitation, only: [:show, :accept, :destroy] + + def index + authorize @family, :show? + @pending_invitations = @family.family_invitations.pending + end + + def show + # Public endpoint for invitation acceptance + end + + def create + authorize @family, :invite? + + service = Families::InviteService.new( + family: @family, + email: invitation_params[:email], + invited_by: current_user + ) + + if service.call + redirect_to family_path(@family), notice: 'Invitation sent successfully!' + else + redirect_to family_path(@family), alert: 'Failed to send invitation' + end + end + + def accept + authenticate_user! + + service = Families::AcceptInvitationService.new( + invitation: @invitation, + user: current_user + ) + + if service.call + redirect_to family_path(current_user.family), notice: 'Welcome to the family!' + else + redirect_to root_path, alert: 'Unable to accept invitation' + end + end + + def destroy + authorize @family, :manage_invitations? + + @invitation.update!(status: :cancelled) + redirect_to family_path(@family), notice: 'Invitation cancelled' + end + + private + + def set_family + @family = current_user.family + redirect_to families_path unless @family + end + + def set_invitation + @invitation = FamilyInvitation.find_by!(token: params[:id]) + end + + def invitation_params + params.require(:family_invitation).permit(:email) + end +end +``` + +## Pundit Policies + +### 1. FamilyPolicy +```ruby +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 + user.active? && user.active_until&.future? + 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 +``` + +### 2. FamilyMembershipPolicy +```ruby +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 +``` + +## Mailers + +### FamilyMailer +```ruby +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 +end +``` + +### Email Templates + +#### `app/views/family_mailer/invitation.html.erb` +```erb +

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:

+ + +

+ <%= link_to "Accept Invitation", @accept_url, + style: "background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;" %> +

+ +

Note: 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,
+ The Dawarich Team +

+``` + +## Routes + +### `config/routes.rb` additions +```ruby +# Family routes +resources :families, except: [:index] do + member do + post :leave + end + + resources :members, controller: 'family_memberships', except: [:new, :create] + resources :invitations, controller: 'family_invitations', except: [:edit, :update] do + member do + post :accept + end + end +end + +# Public invitation acceptance +get '/family_invitations/:id', to: 'family_invitations#show', as: 'family_invitation' + +# Family index/dashboard +get '/family', to: 'families#index', as: 'family_dashboard' +``` + +## Views + +### 1. Family Dashboard (`app/views/families/show.html.erb`) +```erb +
+
+

+ <%= @family.name %> +

+ + <% if policy(@family).update? %> +
+ <%= link_to "Settings", edit_family_path(@family), + class: "btn btn-outline" %> + <%= link_to "Leave Family", leave_family_path(@family), + method: :post, + confirm: "Are you sure you want to leave this family?", + class: "btn btn-error" %> +
+ <% end %> +
+ + +
+
+
+

Family Locations

+
+ +
+
+
+
+ + +
+
+
+
+

Family Members (<%= @members.count %>/<%= Family::MAX_MEMBERS %>)

+ + <% if policy(@family).invite? && @members.count < Family::MAX_MEMBERS %> + + <% end %> +
+ +
+ <% @members.each do |member| %> +
+
+
+
+ <%= member.email.first.upcase %> +
+
+ +
+
<%= member.email %>
+
+ <%= member.family_membership.role.humanize %> + <% unless member.family_membership.location_sharing_enabled? %> + โ€ข Location sharing disabled + <% end %> +
+
+
+ + <% if policy(@family).update? && member != current_user %> + + <% end %> +
+ <% end %> +
+
+
+ + + <% if policy(@family).manage_invitations? && @pending_invitations.any? %> +
+
+

Pending Invitations

+ +
+ <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email %>
+
+ Expires <%= time_ago_in_words(invitation.expires_at) %> from now +
+
+ + <%= link_to "Cancel", family_invitation_path(@family, invitation), + method: :delete, + confirm: "Cancel invitation to #{invitation.email}?", + class: "btn btn-error btn-sm" %> +
+ <% end %> +
+
+
+ <% end %> +
+
+ + +<% if policy(@family).invite? %> + + + +<% end %> + + +``` + +### 2. Create Family (`app/views/families/new.html.erb`) +```erb +
+
+
+

Create Your Family

+ + <%= form_with model: @family, local: true do |form| %> + <% if @family.errors.any? %> +
+
+

Please fix the following errors:

+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+ <% end %> + +
+ <%= form.label :name, "Family Name", class: "label" %> + <%= form.text_field :name, + class: "input input-bordered w-full", + placeholder: "e.g., The Smith Family" %> + +
+ +
+ <%= link_to "Cancel", root_path, class: "btn btn-ghost" %> + <%= form.submit "Create Family", class: "btn btn-primary" %> +
+ <% end %> + +
+ +
+

Family Features:

+
    +
  • Share your current location with up to <%= Family::MAX_MEMBERS - 1 %> family members
  • +
  • See where your family members are right now
  • +
  • Control your privacy with sharing toggles
  • +
  • Invite members by email
  • +
+
+
+
+
+``` + +### 3. Family Settings (`app/views/families/edit.html.erb`) +```erb +
+
+
+

Family Settings

+ + <%= form_with model: @family, local: true do |form| %> + <% if @family.errors.any? %> +
+
+

Please fix the following errors:

+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+ <% end %> + + +
+ <%= form.label :name, "Family Name", class: "label" %> + <%= form.text_field :name, + class: "input input-bordered w-full" %> +
+ + +
+

Your Location Sharing

+ + <%= form_with model: [current_user.family, current_user.family_membership], + url: family_member_path(current_user.family, current_user.family_membership), + method: :patch, local: true do |membership_form| %> + + + +
+ <%= membership_form.submit "Update Sharing Settings", + class: "btn btn-sm btn-outline" %> +
+ <% end %> +
+ +
+ + +
+

Family Management

+ +
+
+

Danger Zone

+

These actions cannot be undone

+
+
+ + <% if @family.members.count <= 1 %> + <%= link_to "Delete Family", + family_path(@family), + method: :delete, + confirm: "Are you sure? This will permanently delete your family.", + class: "btn btn-error" %> + <% else %> +
+ To delete this family, you must first remove all other members. +
+ <% end %> +
+ +
+ <%= link_to "Back to Family", family_path(@family), class: "btn btn-ghost" %> + <%= form.submit "Save Changes", class: "btn btn-primary" %> +
+ <% end %> +
+
+
+``` + +### 4. Public Invitation Page (`app/views/family_invitations/show.html.erb`) +```erb +
+
+
+

Family Invitation

+ + <% if @invitation.pending? && @invitation.expires_at > Time.current %> +
+
+
๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ
+

You're Invited!

+
+ +

+ <%= @invitation.invited_by.email %> has invited you to join + "<%= @invitation.family.name %>" on Dawarich. +

+ +
+

What you'll get:

+
    +
  • Share your current location with family
  • +
  • See where your family members are
  • +
  • Stay connected and safe
  • +
  • Full control over your privacy
  • +
+
+ +
+ This invitation expires in + <%= time_ago_in_words(@invitation.expires_at) %> +
+
+ + <% if user_signed_in? %> + <% if current_user.email == @invitation.email %> + <%= link_to "Accept Invitation", + accept_family_invitation_path(@invitation.token), + method: :post, + class: "btn btn-primary w-full" %> + <% else %> +
+
+

This invitation is for <%= @invitation.email %>.

+

You're signed in as <%= current_user.email %>.

+

Please sign out and sign in with the correct account, or create a new account with the invited email.

+
+
+ + <%= link_to "Sign Out", destroy_user_session_path, + method: :delete, class: "btn btn-outline w-full" %> + <% end %> + <% else %> +
+ <%= link_to "Sign In to Accept", + new_user_session_path(email: @invitation.email), + class: "btn btn-primary w-full" %> + +
OR
+ + <%= link_to "Create Account", + new_user_registration_path(email: @invitation.email), + class: "btn btn-outline w-full" %> +
+ <% end %> + + <% else %> +
+
โฐ
+

Invitation Expired

+

+ This family invitation has expired or is no longer valid. +

+ + <%= link_to "Go to Dawarich", root_path, class: "btn btn-primary" %> +
+ <% end %> +
+
+
+``` + +## Navigation Integration + +### Update `app/views/shared/_navbar.html.erb` +```erb + +<% if user_signed_in? %> +
  • + <% if current_user.in_family? %> + <%= link_to family_path(current_user.family), class: "flex items-center space-x-2" do %> + + + + Family + <% end %> + <% else %> + <%= link_to new_family_path, class: "flex items-center space-x-2" do %> + + + + Create Family + <% end %> + <% end %> +
  • +<% end %> +``` + +## Testing Strategy + +### 1. Model Tests +```ruby +# spec/models/family_spec.rb +RSpec.describe Family, type: :model do + describe 'associations' do + it { is_expected.to have_many(:family_memberships).dependent(:destroy) } + it { is_expected.to have_many(:members).through(:family_memberships) } + it { is_expected.to have_many(:family_invitations).dependent(:destroy) } + it { is_expected.to belong_to(:creator) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(50) } + it { is_expected.to validate_presence_of(:creator_id) } + end + + describe 'constants' do + it 'defines MAX_MEMBERS' do + expect(Family::MAX_MEMBERS).to eq(5) + end + end +end + +# spec/models/family_membership_spec.rb +RSpec.describe FamilyMembership, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:family) } + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:family_id) } + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_uniqueness_of(:user_id) } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:role).with_values(owner: 0, member: 1) } + it { is_expected.to define_enum_for(:status).with_values(active: 0, inactive: 1) } + end +end + +# spec/models/family_invitation_spec.rb +RSpec.describe FamilyInvitation, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:family) } + it { is_expected.to belong_to(:invited_by) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:email) } + it { is_expected.to allow_value('test@example.com').for(:email) } + it { should_not allow_value('invalid-email').for(:email) } + it { is_expected.to validate_presence_of(:token) } + it { is_expected.to validate_uniqueness_of(:token) } + end + + describe 'callbacks' do + it 'generates token on create' do + invitation = build(:family_invitation, token: nil) + invitation.save + expect(invitation.token).to be_present + end + + it 'sets expiry on create' do + invitation = build(:family_invitation, expires_at: nil) + invitation.save + expect(invitation.expires_at).to be_within(1.minute).of(7.days.from_now) + end + end +end +``` + +### 2. Service Tests +```ruby +# spec/services/families/create_service_spec.rb +RSpec.describe Families::CreateService do + let(:user) { create(:user) } + let(:service) { described_class.new(user: user, name: 'Test Family') } + + describe '#call' do + context 'when user is not in a family' do + it 'creates a family successfully' do + expect { service.call }.to change(Family, :count).by(1) + expect(service.family.name).to eq('Test Family') + expect(service.family.creator).to eq(user) + end + + it 'creates owner membership' do + service.call + membership = user.family_membership + expect(membership.role).to eq('owner') + expect(membership.status).to eq('active') + end + + it 'sends notification' do + expect(Notifications::Create).to receive(:new).and_call_original + service.call + end + end + + context 'when user is already in a family' do + before { create(:family_membership, user: user) } + + it 'returns false' do + expect(service.call).to be_falsey + end + + it 'does not create a family' do + expect { service.call }.not_to change(Family, :count) + end + end + end +end +``` + +### 3. Controller Tests +```ruby +# spec/controllers/families_controller_spec.rb +RSpec.describe FamiliesController, type: :controller do + let(:user) { create(:user) } + + before { sign_in user } + + describe 'GET #show' do + context 'when user has a family' do + let(:family) { create(:family, creator: user) } + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + + it 'renders the show template' do + get :show, params: { id: family.id } + expect(response).to render_template(:show) + expect(assigns(:family)).to eq(family) + end + end + + context 'when user has no family' do + it 'redirects to families index' do + get :show, params: { id: 'nonexistent' } + expect(response).to redirect_to(families_path) + end + end + end + + describe 'POST #create' do + let(:valid_params) { { family: { name: 'Test Family' } } } + + it 'creates a family successfully' do + expect { post :create, params: valid_params }.to change(Family, :count).by(1) + expect(response).to redirect_to(family_path(Family.last)) + end + + context 'with invalid params' do + let(:invalid_params) { { family: { name: '' } } } + + it 'renders new template with errors' do + post :create, params: invalid_params + expect(response).to render_template(:new) + expect(response.status).to eq(422) + end + end + end +end +``` + +### 4. Integration Tests +```ruby +# spec/requests/family_workflow_spec.rb +RSpec.describe 'Family Workflow', type: :request do + let(:owner) { create(:user, email: 'owner@example.com') } + let(:invitee_email) { 'member@example.com' } + + before { sign_in owner } + + describe 'complete family creation and invitation flow' do + it 'allows creating family, inviting member, and accepting invitation' do + # Create family + post '/families', params: { family: { name: 'Test Family' } } + expect(response).to redirect_to(family_path(Family.last)) + + family = Family.last + expect(family.name).to eq('Test Family') + expect(family.creator).to eq(owner) + + # Invite member + post "/families/#{family.id}/invitations", + params: { family_invitation: { email: invitee_email } } + expect(response).to redirect_to(family_path(family)) + + invitation = FamilyInvitation.last + expect(invitation.email).to eq(invitee_email) + expect(invitation.status).to eq('pending') + + # Create invitee user and accept invitation + invitee = create(:user, email: invitee_email) + sign_in invitee + + post "/family_invitations/#{invitation.token}/accept" + expect(response).to redirect_to(family_path(family)) + + # Verify membership created + membership = invitee.family_membership + expect(membership.family).to eq(family) + expect(membership.role).to eq('member') + expect(membership.status).to eq('active') + + # Verify invitation updated + invitation.reload + expect(invitation.status).to eq('accepted') + end + end +end +``` + +### 5. System Tests +```ruby +# spec/system/family_management_spec.rb +RSpec.describe 'Family Management', type: :system do + let(:user) { create(:user) } + + before do + sign_in user + visit '/' + end + + it 'allows user to create and manage a family' do + # Create family + click_link 'Create Family' + fill_in 'Family Name', with: 'The Smith Family' + click_button 'Create Family' + + expect(page).to have_content('Family created successfully!') + expect(page).to have_content('The Smith Family') + + # Invite member + click_button 'Invite Member' + fill_in 'Email Address', with: 'member@example.com' + click_button 'Send Invitation' + + expect(page).to have_content('Invitation sent successfully!') + expect(page).to have_content('member@example.com') + end +end +``` + +## Feature Gating for Cloud vs Self-Hosted + +### Update DawarichSettings +```ruby +# config/initializers/03_dawarich_settings.rb + +class DawarichSettings + # ... existing code ... + + def self.family_feature_enabled? + @family_feature_enabled ||= self_hosted? || family_subscription_active? + end + + def self.family_subscription_active? + # Will be implemented when cloud subscriptions are added + # For now, return false for cloud instances + false + end + + def self.family_max_members + @family_max_members ||= self_hosted? ? Family::MAX_MEMBERS : subscription_family_limit + end + + private + + def self.subscription_family_limit + # Will be implemented based on subscription tiers + # For now, return basic limit + Family::MAX_MEMBERS + end +end +``` + +### Add to Routes +```ruby +# config/routes.rb + +# Family routes - only if feature is enabled +if Rails.application.config.after_initialize_block.nil? + Rails.application.config.after_initialize do + if DawarichSettings.family_feature_enabled? + # Family routes will be added here + end + end +end +``` + +## Implementation Phases + +### Phase 1: Database Foundation (Week 1) โœ… COMPLETED +1. โœ… Create migration files for all three tables +2. โœ… Implement base model classes with associations +3. โœ… Add basic validations and enums +4. โœ… Create and run migrations +5. โœ… Write comprehensive model tests + +### Phase 2: Core Business Logic (Week 2) +1. Implement all service classes +2. Add invitation token generation and expiry logic +3. Create email templates and mailer +4. Write service tests +5. Add basic Pundit policies + +### Phase 3: Controllers and Routes (Week 3) +1. Implement all controller classes +2. Add route definitions +3. Create basic authorization policies +4. Write controller tests +5. Add request/integration tests + +### Phase 4: User Interface (Week 4) +1. Create all view templates +2. Add family navigation to main nav +3. Implement basic map integration for family locations +4. Add Stimulus controllers for interactive elements +5. Write system tests for UI flows + +### Phase 5: Polish and Testing (Week 5) +1. Add comprehensive error handling +2. Improve UI/UX based on testing +3. Add feature gating for cloud vs self-hosted +4. Performance optimization +5. Documentation and deployment preparation + +## Security Considerations + +1. **UUID Primary Keys**: All family-related tables use UUIDs to prevent enumeration attacks +2. **Token-based Invitations**: Secure, unguessable invitation tokens with expiry +3. **Authorization Policies**: Comprehensive Pundit policies for all actions +4. **Data Privacy**: Users control their own location sharing settings +5. **Account Protection**: Family owners cannot delete accounts while managing families +6. **Email Validation**: Proper email format validation for invitations +7. **Rate Limiting**: is_expected.to be added for invitation sending (future enhancement) + +## Performance Considerations + +1. **Database Indexes**: Proper indexing on foreign keys and query patterns +2. **Eager Loading**: Use `includes()` for associations in controllers +3. **Caching**: Cache family locations for map display +4. **Background Jobs**: Use Sidekiq for email sending +5. **Pagination**: Add pagination for large families (future enhancement) + +## Future Enhancements + +1. **Historical Location Sharing**: Allow sharing location history with permissions +2. **Family Messaging**: Add simple messaging between family members +3. **Geofencing**: Notifications when family members enter/leave areas +4. **Family Events**: Plan and track family trips together +5. **Emergency Features**: Quick location sharing in emergency situations +6. **Mobile App Push Notifications**: Real-time location updates +7. **Family Statistics**: Aggregate family travel statistics +8. **Multiple Families**: Allow users to be in multiple families with different roles + +This comprehensive implementation plan provides a solid foundation for the family feature while maintaining Dawarich's existing patterns and ensuring security, privacy, and performance. diff --git a/app/models/family.rb b/app/models/family.rb new file mode 100644 index 00000000..5bdf19ee --- /dev/null +++ b/app/models/family.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Family < ApplicationRecord + has_many :family_memberships, dependent: :destroy + has_many :members, through: :family_memberships, source: :user + has_many :family_invitations, dependent: :destroy + belongs_to :creator, class_name: 'User' + + validates :name, presence: true, length: { maximum: 50 } + + MAX_MEMBERS = 5 + + def can_add_members? + members.count < MAX_MEMBERS + end +end diff --git a/app/models/family_invitation.rb b/app/models/family_invitation.rb new file mode 100644 index 00000000..71a75e9a --- /dev/null +++ b/app/models/family_invitation.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class FamilyInvitation < ApplicationRecord + 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, presence: true + validates :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 + + def expired? + expires_at < Time.current + 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 +end diff --git a/app/models/family_membership.rb b/app/models/family_membership.rb new file mode 100644 index 00000000..dc20cb95 --- /dev/null +++ b/app/models/family_membership.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class FamilyMembership < ApplicationRecord + belongs_to :family + belongs_to :user + + validates :user_id, presence: true, uniqueness: true # One family per user + validates :role, presence: true + + enum :role, { owner: 0, member: 1 } +end diff --git a/app/models/user.rb b/app/models/user.rb index bde8e853..e9d9b71b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,12 +15,21 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy + # Family associations + has_one :family_membership, dependent: :destroy + has_one :family, through: :family_membership + has_many :created_families, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy + has_many :sent_family_invitations, class_name: 'FamilyInvitation', foreign_key: 'invited_by_id', +inverse_of: :invited_by, dependent: :destroy + after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? } before_save :sanitize_input + before_destroy :check_family_ownership + validates :email, presence: true validates :reset_password_token, uniqueness: true, allow_nil: true @@ -162,6 +171,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength settings.try(:[], 'maps')&.try(:[], 'url')&.strip! end + 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 + def start_trial update(status: :trial, active_until: 7.days.from_now) schedule_welcome_emails @@ -181,4 +197,42 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early') Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late') end + + public + + # Family-related methods + 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? + in_family? + end + + def latest_location_for_family + return nil unless family_sharing_enabled? + + latest_point = points.order(timestamp: :desc).first + return nil unless latest_point + + { + user_id: id, + email: email, + latitude: latest_point.latitude, + longitude: latest_point.longitude, + timestamp: latest_point.timestamp, + updated_at: Time.at(latest_point.timestamp) + } + end end diff --git a/db/migrate/20250926220114_create_families.rb b/db/migrate/20250926220114_create_families.rb new file mode 100644 index 00000000..29c58dd8 --- /dev/null +++ b/db/migrate/20250926220114_create_families.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateFamilies < ActiveRecord::Migration[8.0] + def change + create_table :families, id: :uuid 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 diff --git a/db/migrate/20250926220135_create_family_memberships.rb b/db/migrate/20250926220135_create_family_memberships.rb new file mode 100644 index 00000000..15cf931a --- /dev/null +++ b/db/migrate/20250926220135_create_family_memberships.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateFamilyMemberships < ActiveRecord::Migration[8.0] + def change + create_table :family_memberships, id: :uuid do |t| + t.uuid :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, :family_id + add_index :family_memberships, :user_id, unique: true # One family per user + add_index :family_memberships, %i[family_id role] + end +end diff --git a/db/migrate/20250926220158_create_family_invitations.rb b/db/migrate/20250926220158_create_family_invitations.rb new file mode 100644 index 00000000..f11a2c60 --- /dev/null +++ b/db/migrate/20250926220158_create_family_invitations.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateFamilyInvitations < ActiveRecord::Migration[8.0] + def change + create_table :family_invitations, id: :uuid do |t| + t.uuid :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, :family_id + add_index :family_invitations, :email + add_index :family_invitations, :token, unique: true + add_index :family_invitations, :status + add_index :family_invitations, :expires_at + end +end diff --git a/db/migrate/20250926220345_validate_family_foreign_keys.rb b/db/migrate/20250926220345_validate_family_foreign_keys.rb new file mode 100644 index 00000000..45461b79 --- /dev/null +++ b/db/migrate/20250926220345_validate_family_foreign_keys.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index d097aca9..4b0ad5ab 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # 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_26_220345) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + enable_extension "pgcrypto" enable_extension "postgis" create_table "action_text_rich_texts", force: :cascade do |t| @@ -96,6 +97,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", id: :uuid, default: -> { "gen_random_uuid()" }, 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", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "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", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "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 +343,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" + add_foreign_key "family_invitations", "families" + add_foreign_key "family_invitations", "users", column: "invited_by_id" + add_foreign_key "family_memberships", "families" + add_foreign_key "family_memberships", "users" add_foreign_key "notifications", "users" add_foreign_key "place_visits", "places" add_foreign_key "place_visits", "visits" diff --git a/spec/factories/families.rb b/spec/factories/families.rb new file mode 100644 index 00000000..9958a049 --- /dev/null +++ b/spec/factories/families.rb @@ -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 diff --git a/spec/factories/family_invitations.rb b/spec/factories/family_invitations.rb new file mode 100644 index 00000000..5d3e7785 --- /dev/null +++ b/spec/factories/family_invitations.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :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 diff --git a/spec/factories/family_memberships.rb b/spec/factories/family_memberships.rb new file mode 100644 index 00000000..9979df5a --- /dev/null +++ b/spec/factories/family_memberships.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family_membership do + association :family + association :user + role { :member } + + trait :owner do + role { :owner } + end + end +end diff --git a/spec/models/family_invitation_spec.rb b/spec/models/family_invitation_spec.rb new file mode 100644 index 00000000..3792bd72 --- /dev/null +++ b/spec/models/family_invitation_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe FamilyInvitation, 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(FamilyInvitation.active).to include(pending_invitation) + expect(FamilyInvitation.active).not_to include(expired_invitation) + expect(FamilyInvitation.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(FamilyInvitation::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(FamilyInvitation::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 diff --git a/spec/models/family_membership_spec.rb b/spec/models/family_membership_spec.rb new file mode 100644 index 00000000..96d76f07 --- /dev/null +++ b/spec/models/family_membership_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe FamilyMembership, 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 diff --git a/spec/models/family_spec.rb b/spec/models/family_spec.rb new file mode 100644 index 00000000..a6d6cf12 --- /dev/null +++ b/spec/models/family_spec.rb @@ -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(FamilyInvitation, :count).by(-1) + expect(FamilyInvitation.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(FamilyMembership, :count).by(-1) + expect(FamilyMembership.find_by(id: membership.id)).to be_nil + end + end +end diff --git a/spec/models/user_family_spec.rb b/spec/models/user_family_spec.rb new file mode 100644 index 00000000..bdcea5c8 --- /dev/null +++ b/spec/models/user_family_spec.rb @@ -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_many(:created_families).class_name('Family').with_foreign_key('creator_id').dependent(:destroy) + } + it { + is_expected.to have_many(:sent_family_invitations).class_name('FamilyInvitation').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(FamilyInvitation, :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(FamilyMembership, :count).by(-1) + end + end + end +end