Add implementation plan and complete phase 1

This commit is contained in:
Eugene Burmakin 2025-09-27 00:46:29 +02:00
parent 4287fee93d
commit 0d02f08199
17 changed files with 2430 additions and 1 deletions

1650
FAMILY_PLAN.md Normal file

File diff suppressed because it is too large Load diff

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

43
db/schema.rb generated
View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,177 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 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

View file

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

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

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

View file

@ -0,0 +1,136 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe User, 'family methods', type: :model do
let(:user) { create(:user) }
describe 'family associations' do
it { is_expected.to have_one(:family_membership).dependent(:destroy) }
it { is_expected.to have_one(:family).through(:family_membership) }
it {
is_expected.to have_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