diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb index 1e0dfed85ef..5e12d2df994 100644 --- a/app/controllers/concerns/invitable.rb +++ b/app/controllers/concerns/invitable.rb @@ -7,6 +7,7 @@ module Invitable private def invite_code_required? + return false if @invitation.present? self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true" end diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 00000000000..020b4707ca9 --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -0,0 +1,42 @@ +class InvitationsController < ApplicationController + skip_authentication only: :accept + def new + @invitation = Invitation.new + end + + def create + unless Current.user.admin? + flash[:alert] = t(".failure") + redirect_to settings_profile_path + return + end + + @invitation = Current.family.invitations.build(invitation_params) + @invitation.inviter = Current.user + + if @invitation.save + InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted? + flash[:notice] = t(".success") + else + flash[:alert] = t(".failure") + end + + redirect_to settings_profile_path + end + + def accept + @invitation = Invitation.find_by!(token: params[:id]) + + if @invitation.pending? + redirect_to new_registration_path(invitation: @invitation.token) + else + raise ActiveRecord::RecordNotFound + end + end + + private + + def invitation_params + params.require(:invitation).permit(:email, :role) + end +end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb index 4fb5386f8f2..a98694712a8 100644 --- a/app/controllers/onboardings_controller.rb +++ b/app/controllers/onboardings_controller.rb @@ -1,7 +1,7 @@ class OnboardingsController < ApplicationController layout "application" - before_action :set_user + before_action :load_invitation def show end @@ -13,7 +13,12 @@ def preferences end private + def set_user @user = Current.user end + + def load_invitation + @invitation = Invitation.accepted.most_recent_for_email(Current.user.email) + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 8c55e58d03a..b5613d443f3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController layout "auth" before_action :set_user, only: :create + before_action :set_invitation before_action :claim_invite_code, only: :create, if: :invite_code_required? def new - @user = User.new + @user = User.new(email: @invitation&.email) end def create - family = Family.new - @user.family = family - @user.role = :admin + if @invitation + @user.family = @invitation.family + @user.role = @invitation.role + @user.email = @invitation.email + else + family = Family.new + @user.family = family + @user.role = :admin + end if @user.save - Category.create_default_categories(@user.family) + @invitation&.update!(accepted_at: Time.current) + Category.create_default_categories(@user.family) unless @invitation @session = create_session_for(@user) - flash[:notice] = t(".success") - redirect_to root_path + redirect_to root_path, notice: t(".success") else - flash[:alert] = t(".failure") render :new, status: :unprocessable_entity end end private + def set_invitation + token = params[:invitation] + token ||= params[:user][:invitation] if params[:user].present? + @invitation = Invitation.pending.find_by(token: token) + end + def set_user - @user = User.new user_params.except(:invite_code) + @user = User.new user_params.except(:invite_code, :invitation) end - def user_params - params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code) + def user_params(specific_param = nil) + params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation) + specific_param ? params[specific_param] : params end def claim_invite_code diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0caca54c196..882824ecb64 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,5 +1,7 @@ class Settings::ProfilesController < SettingsController def show @user = Current.user + @users = Current.family.users.order(:created_at) + @pending_invitations = Current.family.invitations.pending end end diff --git a/app/helpers/invitations_helper.rb b/app/helpers/invitations_helper.rb new file mode 100644 index 00000000000..1483b9eeb7f --- /dev/null +++ b/app/helpers/invitations_helper.rb @@ -0,0 +1,2 @@ +module InvitationsHelper +end diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb new file mode 100644 index 00000000000..d43a42fe60b --- /dev/null +++ b/app/mailers/invitation_mailer.rb @@ -0,0 +1,11 @@ +class InvitationMailer < ApplicationMailer + def invite_email(invitation) + @invitation = invitation + @accept_url = accept_invitation_url(@invitation.token) + + mail( + to: @invitation.email, + subject: t(".subject", inviter: @invitation.inviter.display_name) + ) + end +end diff --git a/app/models/family.rb b/app/models/family.rb index 80f392753d9..8d0d063bfbb 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -4,6 +4,7 @@ class Family < ApplicationRecord include Providable has_many :users, dependent: :destroy + has_many :invitations, dependent: :destroy has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy has_many :institutions, dependent: :destroy diff --git a/app/models/invitation.rb b/app/models/invitation.rb new file mode 100644 index 00000000000..41770b50ac4 --- /dev/null +++ b/app/models/invitation.rb @@ -0,0 +1,37 @@ +class Invitation < ApplicationRecord + belongs_to :family + belongs_to :inviter, class_name: "User" + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :role, presence: true, inclusion: { in: %w[admin member] } + validates :token, presence: true, uniqueness: true + validate :inviter_is_admin + + before_validation :generate_token, on: :create + before_create :set_expiration + + scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) } + scope :accepted, -> { where.not(accepted_at: nil) } + scope :most_recent_for_email, ->(email) { where(email: email).order(accepted_at: :desc).first } + + def pending? + accepted_at.nil? && expires_at > Time.current + end + + private + + def generate_token + loop do + self.token = SecureRandom.hex(32) + break unless self.class.exists?(token: token) + end + end + + def set_expiration + self.expires_at = 3.days.from_now + end + + def inviter_is_admin + inviter.admin? + end +end diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb new file mode 100644 index 00000000000..c57be0a9492 --- /dev/null +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -0,0 +1,11 @@ +

<%= t(".greeting") %>

+ +

+ <%= t(".body", + inviter: @invitation.inviter.display_name, + family: @invitation.family.name).html_safe %> +

+ +<%= link_to t(".accept_button"), @accept_url, class: "button" %> + + \ No newline at end of file diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb new file mode 100644 index 00000000000..5b6a455062e --- /dev/null +++ b/app/views/invitations/new.html.erb @@ -0,0 +1,20 @@ +<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %> + <%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %> + <%= form.email_field :email, + required: true, + placeholder: t(".email_placeholder"), + label: t(".email_label") %> + + <%= form.select :role, + options_for_select([ + [t(".role_member"), "member"], + [t(".role_admin"), "admin"] + ]), + {}, + { label: t(".role_label") } %> + +
+ <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2 w-full" %> +
+ <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 3aac9002edc..2d6f8c06d7e 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -2,12 +2,56 @@ + - <%= yield %> +
+ <%= yield %> +
diff --git a/app/views/onboardings/profile.html.erb b/app/views/onboardings/profile.html.erb index e2bda5b7567..0e0b99f5732 100644 --- a/app/views/onboardings/profile.html.erb +++ b/app/views/onboardings/profile.html.erb @@ -9,7 +9,8 @@ <%= styled_form_with model: @user do |form| %> - <%= form.hidden_field :redirect_to, value: "onboarding_preferences" %> + <%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %> + <%= form.hidden_field :onboarded_at, value: Time.current if @invitation %>

<%= t(".profile_image") %>

@@ -20,16 +21,17 @@ <%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name"), container_class: "bg-white w-1/2", required: true %> <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name"), container_class: "bg-white w-1/2", required: true %>
- -
- <%= form.fields_for :family do |family_form| %> - <%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %> - - <%= family_form.select :country, - country_options, - { label: t(".country") }, required: true %> - <% end %> -
+ <% unless @invitation %> +
+ <%= form.fields_for :family do |family_form| %> + <%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %> + + <%= family_form.select :country, + country_options, + { label: t(".country") }, required: true %> + <% end %> +
+ <% end %> <%= form.submit t(".submit") %> <% end %> diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index df7200fe211..6ad1f066e07 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,5 +1,5 @@ <% - header_title t(".title") + header_title @invitation ? t(".join_family_title", family: @invitation.family.name) : t(".title") %> <% if self_hosted_first_login? %> @@ -7,14 +7,29 @@

<%= t(".welcome_title") %>

<%= t(".welcome_body") %>

+<% elsif @invitation %> +
+

+ <%= t(".invitation_message", + inviter: @invitation.inviter.display_name, + role: t(".role_#{@invitation.role}")) %> +

+
<% end %> <%= styled_form_with model: @user, url: registration_path, class: "space-y-4" do |form| %> - <%= form.email_field :email, autofocus: false, autocomplete: "email", required: "required", placeholder: "you@example.com", label: true %> + <%= form.email_field :email, + autofocus: false, + autocomplete: "email", + required: "required", + placeholder: "you@example.com", + label: true, + disabled: @invitation.present? %> <%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %> <%= form.password_field :password_confirmation, autocomplete: "new-password", required: "required", label: true %> - <% if invite_code_required? %> + <% if invite_code_required? && !@invitation %> <%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %> <% end %> + <%= form.hidden_field :invitation, value: @invitation&.token %> <%= form.submit t(".submit") %> <% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index c1c7cb8d098..dde691a2de4 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -34,15 +34,60 @@

<%= Current.family.name %> · <%= Current.family.users.size %>

-
-
-

<%= Current.user.initial %>

+ <% @users.each do |user| %> +
+
+ <%= render "settings/user_avatar", user: user %> +
+

<%= user.display_name %>

+
+

<%= user.role %>

+
-

<%= Current.user.display_name %>

-
-

<%= Current.user.role %>

-
-
+ <% end %> + <% if @pending_invitations.any? %> + <% @pending_invitations.each do |invitation| %> +
+
+
+
<%= invitation.email[0] %>
+
+
+

<%= invitation.email %>

+
+

<%= t(".pending") %>

+
+
+
+ <% if self_hosted? %> +
+

<%= t(".invitation_link") %>

+ + + +
+ <% end %> +
+ <% end %> + <% end %> + <% if Current.user.admin? %> + <%= link_to new_invitation_path, + class: "bg-gray-100 flex items-center justify-center gap-2 text-gray-500 mt-1 hover:bg-gray-200 rounded-lg px-4 py-2 w-full text-center", + data: { turbo_frame: :modal } do %> + <%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %> + <%= t(".invite_member") %> + <% end %> + <% end %>
<% end %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 78648c48283..e3134b174f6 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -38,7 +38,7 @@ "type": "controller", "class": "AccountsController", "method": "show", - "line": 39, + "line": 36, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -72,7 +72,7 @@ "type": "controller", "class": "AccountsController", "method": "show", - "line": 39, + "line": 36, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -91,6 +91,29 @@ ], "note": "" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "aaccd8db0be34afdc88e5af08d91ae2e8b7765dfea2f3fc6e1c37db0adc7b991", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/invitations_controller.rb", + "line": 34, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:invitation).permit(:email, :role)", + "render_path": null, + "location": { + "type": "method", + "class": "InvitationsController", + "method": "invitation_params" + }, + "user_input": ":role", + "confidence": "Medium", + "cwe_id": [ + 915 + ], + "note": "" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -140,7 +163,7 @@ "type": "controller", "class": "AccountsController", "method": "show", - "line": 39, + "line": 36, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -194,6 +217,6 @@ "note": "" } ], - "updated": "2024-10-17 11:30:15 -0400", - "brakeman_version": "6.2.1" + "updated": "2024-11-01 09:36:40 -0500", + "brakeman_version": "6.2.2" } diff --git a/config/locales/mailers/invitation_mailer/en.yml b/config/locales/mailers/invitation_mailer/en.yml new file mode 100644 index 00000000000..1bb494e83e9 --- /dev/null +++ b/config/locales/mailers/invitation_mailer/en.yml @@ -0,0 +1,8 @@ +en: + invitation_mailer: + invite_email: + subject: "%{inviter} has invited you to join their household on Maybe!" + greeting: "You've been invited!" + body: "%{inviter} has invited you to join their household '%{family}' as a %{role}." + accept_button: "Accept Invitation" + expiry_notice: "This invitation will expire in %{days} days." diff --git a/config/locales/views/invitation_mailer/en.yml b/config/locales/views/invitation_mailer/en.yml new file mode 100644 index 00000000000..650377213ff --- /dev/null +++ b/config/locales/views/invitation_mailer/en.yml @@ -0,0 +1,7 @@ +en: + invitation_mailer: + invite_email: + greeting: "Welcome to Maybe!" + body: "%{inviter} has invited you to join the %{family} family on Maybe!" + accept_button: "Accept Invitation" + expiry_notice: "This invitation will expire in %{days} days" diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml new file mode 100644 index 00000000000..389cd35897f --- /dev/null +++ b/config/locales/views/invitations/en.yml @@ -0,0 +1,14 @@ +en: + invitations: + create: + success: "Invitation sent successfully" + failure: "Could not send invitation" + new: + title: Invite Someone + subtitle: Send an invitation to join your family account on Maybe + email_placeholder: Enter email address + email_label: Email Address + role_member: Member + role_admin: Administrator + role_label: Role + submit: Send Invitation \ No newline at end of file diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index 3f2666ee914..5d487ee05f0 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -9,12 +9,15 @@ en: create: Continue registrations: create: - failure: Invalid input, please try again. invalid_invite_code: Invalid invite code, please try again. success: You have signed up successfully. new: submit: Create account - title: Create an account + title: Create your account + join_family_title: "Join %{family}" + invitation_message: "%{inviter} has invited you to join as a %{role}" + role_member: "member" + role_admin: "administrator" welcome_body: To get started, you must sign up for a new account. You will then be able to configure additional settings within the app. welcome_title: Welcome to Self Hosted Maybe! diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 0e2fe635bf2..4bb87e5c3ae 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -41,6 +41,7 @@ en: theme_title: Theme profiles: show: + invite_member: "Add member" confirm_delete: body: Are you sure you want to permanently delete your account? This action is irreversible. @@ -60,6 +61,8 @@ en: profile_subtitle: Customize how you appear on Maybe profile_title: Profile save: Save + pending: Pending + invitation_link: Invitation link user_avatar_field: accepted_formats: JPG or PNG. 5MB max. choose: Choose diff --git a/config/routes.rb b/config/routes.rb index 70e5903292b..83c150b8084 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,10 @@ resources :exchange_rate_provider_missings, only: :update end + resources :invitations, only: [ :new, :create ] do + get :accept, on: :member + end + # For managing self-hosted upgrades and release notifications resources :upgrades, only: [] do member do diff --git a/db/migrate/20241030222235_create_invitations.rb b/db/migrate/20241030222235_create_invitations.rb new file mode 100644 index 00000000000..7b7b2638730 --- /dev/null +++ b/db/migrate/20241030222235_create_invitations.rb @@ -0,0 +1,18 @@ +class CreateInvitations < ActiveRecord::Migration[7.2] + def change + create_table :invitations, id: :uuid do |t| + t.string :email + t.string :role + t.string :token + t.references :family, null: false, foreign_key: true, type: :uuid + t.references :inviter, null: false, foreign_key: { to_table: :users }, type: :uuid + t.datetime :accepted_at + t.datetime :expires_at + + t.timestamps + end + + add_index :invitations, :token, unique: true + add_index :invitations, :email + end +end diff --git a/db/schema.rb b/db/schema.rb index 48a76165aa9..4db6b1457b2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_30_121302) do +ActiveRecord::Schema[7.2].define(version: 2024_10_30_222235) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -119,7 +119,7 @@ t.boolean "is_active", default: true, null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.string "mode" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" @@ -418,6 +418,22 @@ t.datetime "updated_at", null: false end + create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email" + t.string "role" + t.string "token" + t.uuid "family_id", null: false + t.uuid "inviter_id", null: false + t.datetime "accepted_at" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_invitations_on_email" + t.index ["family_id"], name: "index_invitations_on_family_id" + t.index ["inviter_id"], name: "index_invitations_on_inviter_id" + t.index ["token"], name: "index_invitations_on_token", unique: true + end + create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "token", null: false t.datetime "created_at", null: false @@ -605,6 +621,8 @@ add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" add_foreign_key "institutions", "families" + add_foreign_key "invitations", "families" + add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb new file mode 100644 index 00000000000..d6bdcacbe70 --- /dev/null +++ b/test/controllers/invitations_controller_test.rb @@ -0,0 +1,89 @@ +require "test_helper" + +class InvitationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @invitation = invitations(:one) + end + + test "should get new" do + get new_invitation_url + assert_response :success + end + + test "should create invitation for member" do + assert_difference("Invitation.count") do + assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do + post invitations_url, params: { + invitation: { + email: "new@example.com", + role: "member" + } + } + end + end + + invitation = Invitation.order(created_at: :desc).first + assert_equal "member", invitation.role + assert_equal @user, invitation.inviter + assert_equal "new@example.com", invitation.email + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.create.success"), flash[:notice] + end + + test "non-admin cannot create invitations" do + sign_in users(:family_member) + + assert_no_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "new@example.com", + role: "admin" + } + } + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.create.failure"), flash[:alert] + end + + test "admin can create admin invitation" do + assert_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "new@example.com", + role: "admin" + } + } + end + + invitation = Invitation.last + assert_equal "admin", invitation.role + assert_equal @user.family, invitation.family + assert_equal @user, invitation.inviter + end + + test "should handle invalid invitation creation" do + assert_no_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "", + role: "member" + } + } + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.create.failure"), flash[:alert] + end + + test "should accept invitation and redirect to registration" do + get accept_invitation_url(@invitation.token) + assert_redirected_to new_registration_path(invitation: @invitation.token) + end + + test "should not accept invalid invitation token" do + get accept_invitation_url("invalid-token") + assert_response :not_found + end +end diff --git a/test/fixtures/invitations.yml b/test/fixtures/invitations.yml new file mode 100644 index 00000000000..12ae9a052e8 --- /dev/null +++ b/test/fixtures/invitations.yml @@ -0,0 +1,19 @@ +one: + email: "test@example.com" + token: "valid-token-123" + role: "member" + inviter: family_admin + family: dylan_family + created_at: <%= Time.current %> + updated_at: <%= Time.current %> + expires_at: <%= 3.days.from_now %> + +two: + email: "another@example.com" + token: "valid-token-456" + role: "admin" + inviter: family_admin + family: dylan_family + created_at: <%= Time.current %> + updated_at: <%= Time.current %> + expires_at: <%= 3.days.from_now %>