Add limits for import size for trial users

This commit is contained in:
Eugene Burmakin 2025-08-14 20:50:22 +02:00
parent 71488c9fb1
commit 6708e11ab3
24 changed files with 221 additions and 48 deletions

View file

@ -4,11 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.30.9] - 2025-08-11 # [0.30.9] - 2025-08-13
## Added ## Added
- QR code for API key is implemented but hidden under feature flag until the iOS app supports it.
- X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses. - X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses.
- Trial version for cloud users is now available.
# [0.30.8] - 2025-08-01 # [0.30.8] - 2025-08-01

View file

@ -5,7 +5,8 @@ import { showFlashMessage } from "../maps/helpers"
export default class extends Controller { export default class extends Controller {
static targets = ["input", "progress", "progressBar", "submit", "form"] static targets = ["input", "progress", "progressBar", "submit", "form"]
static values = { static values = {
url: String url: String,
userTrial: Boolean
} }
connect() { connect() {
@ -50,6 +51,22 @@ export default class extends Controller {
const files = this.inputTarget.files const files = this.inputTarget.files
if (files.length === 0) return if (files.length === 0) return
// Check file size limits for trial users
if (this.userTrialValue) {
const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE)
if (oversizedFiles.length > 0) {
const fileNames = oversizedFiles.map(f => f.name).join(', ')
const message = `File size limit exceeded. Trial users can only upload files up to 10MB. Oversized files: ${fileNames}`
showFlashMessage('error', message)
// Clear the file input
this.inputTarget.value = ''
return
}
}
console.log(`Uploading ${files.length} files`) console.log(`Uploading ${files.length} files`)
this.isUploading = true this.isUploading = true

View file

@ -0,0 +1,42 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal"]
static values = { showable: Boolean }
connect() {
if (this.showableValue) {
// Listen for Turbo page load events to show modal after navigation completes
document.addEventListener('turbo:load', this.handleTurboLoad.bind(this))
}
}
disconnect() {
// Clean up event listener when controller is removed
document.removeEventListener('turbo:load', this.handleTurboLoad.bind(this))
}
handleTurboLoad() {
if (this.showableValue) {
this.checkAndShowModal()
}
}
checkAndShowModal() {
const MODAL_STORAGE_KEY = 'dawarich_onboarding_shown'
const hasShownModal = localStorage.getItem(MODAL_STORAGE_KEY)
if (!hasShownModal && this.hasModalTarget) {
// Show the modal
this.modalTarget.showModal()
// Mark as shown in local storage
localStorage.setItem(MODAL_STORAGE_KEY, 'true')
// Add event listener to handle when modal is closed
this.modalTarget.addEventListener('close', () => {
// Modal closed - state already saved
})
}
}
}

View file

@ -6,10 +6,14 @@ class Users::TrialWebhookJob < ApplicationJob
def perform(user_id) def perform(user_id)
user = User.find(user_id) user = User.find(user_id)
token = Subscription::EncodeJwtToken.new( payload = {
{ user_id: user.id, email: user.email, action: 'create_user' }, user_id: user.id,
ENV['JWT_SECRET_KEY'] email: user.email,
).call active_until: user.active_until,
action: 'create_user'
}
token = Subscription::EncodeJwtToken.new(payload, ENV['JWT_SECRET_KEY']).call
request_url = "#{ENV['MANAGER_URL']}/api/v1/users" request_url = "#{ENV['MANAGER_URL']}/api/v1/users"
headers = { headers = {

View file

@ -13,6 +13,7 @@ class Import < ApplicationRecord
after_commit :remove_attached_file, on: :destroy after_commit :remove_attached_file, on: :destroy
validates :name, presence: true, uniqueness: { scope: :user_id } validates :name, presence: true, uniqueness: { scope: :user_id }
validate :file_size_within_limit, if: -> { user.trial? }
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 } enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
@ -22,6 +23,18 @@ class Import < ApplicationRecord
user_data_archive: 8 user_data_archive: 8
} }
private
def file_size_within_limit
return unless file.attached?
if file.blob.byte_size > 11.megabytes
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
end
end
public
def process! def process!
if user_data_archive? if user_data_archive?
process_user_data_archive! process_user_data_archive!

View file

@ -98,7 +98,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
end end
def can_subscribe? def can_subscribe?
(active_until.nil? || active_until&.past?) && !DawarichSettings.self_hosted? (trial? || !active_until&.future?) && !DawarichSettings.self_hosted?
end end
def generate_subscription_token def generate_subscription_token
@ -130,7 +130,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
end end
def activate def activate
# TODO: Remove the `status` column in the future.
update(status: :active, active_until: 1000.years.from_now) update(status: :active, active_until: 1000.years.from_now)
end end

View file

@ -11,13 +11,13 @@ class ImportPolicy < ApplicationPolicy
user.present? && record.user == user user.present? && record.user == user
end end
# Users can create new imports if they are active # Users can create new imports if they are active or trial
def new? def new?
create? create?
end end
def create? def create?
user.present? && user.active? user.present? && (user.active? || user.trial?)
end end
# Users can only edit their own imports # Users can only edit their own imports

View file

@ -56,7 +56,12 @@ class Imports::Create
end end
def schedule_visit_suggesting(user_id, import) def schedule_visit_suggesting(user_id, import)
return unless user.safe_settings.visits_suggestions_enabled?
points = import.points.order(:timestamp) points = import.points.order(:timestamp)
return if points.none?
start_at = Time.zone.at(points.first.timestamp) start_at = Time.zone.at(points.first.timestamp)
end_at = Time.zone.at(points.last.timestamp) end_at = Time.zone.at(points.last.timestamp)

View file

@ -2,12 +2,12 @@
<p class='py-2'>Use this API key to authenticate your requests.</p> <p class='py-2'>Use this API key to authenticate your requests.</p>
<code><%= current_user.api_key %></code> <code><%= current_user.api_key %></code>
<%# if ENV['QR_CODE_ENABLED'] == 'true' %> <% if ENV['QR_CODE_ENABLED'] == 'true' %>
<p class='py-2'> <p class='py-2'>
Or you can scan it in your Dawarich iOS app: Or you can scan it in your Dawarich iOS app:
<%= api_key_qr_code(current_user) %> <%= api_key_qr_code(current_user) %>
</p> </p>
<%# end %> <% end %>
<p class='py-2'> <p class='py-2'>
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p> <p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>

View file

@ -1,6 +1,7 @@
<%= form_with model: import, class: "contents", data: { <%= form_with model: import, class: "contents", data: {
controller: "direct-upload", controller: "direct-upload",
direct_upload_url_value: rails_direct_uploads_url, direct_upload_url_value: rails_direct_uploads_url,
direct_upload_user_trial_value: current_user.trial?,
direct_upload_target: "form" direct_upload_target: "form"
} do |form| %> } do |form| %>
<div class="form-control w-full"> <div class="form-control w-full">

View file

@ -1,11 +1,14 @@
<dialog id="getting_started" class="modal"> <% if user_signed_in? %>
<div data-controller="onboarding-modal"
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold">Start tracking your location!</h3> <h3 class="text-lg font-bold">Start tracking your location!</h3>
<p class="py-4"> <p class="py-4">
To start tracking your location and putting it on the map, you need to configure your mobile application. To start tracking your location and putting it on the map, you need to configure your mobile application.
</p> </p>
<p> <p>
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://docs.dawarich.com/mobile-apps/android', class: 'link' %>. To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
</p> </p>
<div class="modal-action"> <div class="modal-action">
<form method="dialog"> <form method="dialog">
@ -14,3 +17,5 @@
</div> </div>
</div> </div>
</dialog> </dialog>
</div>
<% end %>

View file

@ -72,8 +72,7 @@
<div class="navbar-end"> <div class="navbar-end">
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1"> <ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %> <% if user_signed_in? %>
<% if current_user.can_subscribe? %>
<%# if current_user.can_subscribe? %>
<div class="join"> <div class="join">
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %> <%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %>
<span class="join-item btn btn-sm <%= trial_button_class(current_user) %>"> <span class="join-item btn btn-sm <%= trial_button_class(current_user) %>">
@ -83,7 +82,7 @@
</span> </span>
<% end %> <% end %>
</div> </div>
<%# end %> <% end %>
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover" <div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
data-controller="notifications" data-controller="notifications"
@ -159,4 +158,3 @@
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -43,7 +43,7 @@
<p>Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.</p> <p>Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.</p>
</div> </div>
<a href="https://my.dawarich.app" class="cta">Continue Exploring</a> <a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=explore_features&utm_content=continue_exploring" class="cta">Continue Exploring</a>
<p>You have <strong>5 days</strong> left in your trial. Make the most of it!</p> <p>You have <strong>5 days</strong> left in your trial. Make the most of it!</p>

View file

@ -36,7 +36,7 @@
<li>Enjoy beautiful interactive maps</li> <li>Enjoy beautiful interactive maps</li>
</ul> </ul>
<a href="https://my.dawarich.app" class="cta">Subscribe to Continue</a> <a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expired&utm_content=subscribe_to_continue" class="cta">Subscribe to Continue</a>
<p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p> <p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p>

View file

@ -36,11 +36,11 @@
<li>Visit detection and places management</li> <li>Visit detection and places management</li>
</ul> </ul>
<a href="https://my.dawarich.app" class="cta">Subscribe Now</a> <a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expires_soon&utm_content=subscribe_now" class="cta">Subscribe Now</a>
<p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p> <p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p>
<p>Questions? Drop us a message at hi@dawarich.app</p> <p>Questions? Drop us a message at hi@dawarich.app or just reply to this email.</p>
<p>Best regards,<br> <p>Best regards,<br>
Evgenii from Dawarich</p> Evgenii from Dawarich</p>

View file

@ -28,9 +28,9 @@
<li>Export your data in various formats</li> <li>Export your data in various formats</li>
</ul> </ul>
<a href="https://my.dawarich.app" class="cta">Start Exploring Dawarich</a> <a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=welcome&utm_content=start_exploring" class="cta">Start Exploring Dawarich</a>
<p>If you have any questions, feel free to drop us a message at hi@dawarich.app</p> <p>If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.</p>
<p>Happy tracking!<br> <p>Happy tracking!<br>
Evgenii from Dawarich</p> Evgenii from Dawarich</p>

View file

@ -12,7 +12,7 @@ Your 7-day free trial has started. During this time, you can:
Start exploring Dawarich: https://my.dawarich.app Start exploring Dawarich: https://my.dawarich.app
If you have any questions, feel free to drop us a message at hi@dawarich.app If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.
Happy tracking! Happy tracking!
Evgenii from Dawarich Evgenii from Dawarich

View file

@ -36,5 +36,7 @@ module Dawarich
end end
config.active_job.queue_adapter = :sidekiq config.active_job.queue_adapter = :sidekiq
config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews"
end end
end end

View file

@ -1,9 +1,19 @@
# Preview all emails at http://localhost:3000/rails/mailers/users_mailer # frozen_string_literal: true
class UsersMailerPreview < ActionMailer::Preview class UsersMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/users_mailer/welcome
def welcome def welcome
UsersMailer.welcome UsersMailer.with(user: User.last).welcome
end end
def explore_features
UsersMailer.with(user: User.last).explore_features
end
def trial_expires_soon
UsersMailer.with(user: User.last).trial_expires_soon
end
def trial_expired
UsersMailer.with(user: User.last).trial_expired
end
end end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
require "rails_helper" require "rails_helper"
RSpec.describe UsersMailer, type: :mailer do RSpec.describe UsersMailer, type: :mailer do

View file

@ -3,16 +3,69 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Import, type: :model do RSpec.describe Import, type: :model do
let(:user) { create(:user) }
subject(:import) { create(:import, user:) }
describe 'associations' do describe 'associations' do
it { is_expected.to have_many(:points).dependent(:destroy) } it { is_expected.to have_many(:points).dependent(:destroy) }
it { is_expected.to belong_to(:user) } it 'belongs to a user' do
expect(user).to be_present
expect(import.user).to eq(user)
end
end end
describe 'validations' do describe 'validations' do
subject { build(:import, name: 'test import') }
it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) }
it 'validates uniqueness of name scoped to user_id' do
create(:import, name: 'test_name', user: user)
duplicate_import = build(:import, name: 'test_name', user: user)
expect(duplicate_import).not_to be_valid
expect(duplicate_import.errors[:name]).to include('has already been taken')
other_user = create(:user)
different_user_import = build(:import, name: 'test_name', user: other_user)
expect(different_user_import).to be_valid
end
describe 'file size validation' do
context 'when user is a trial user' do
let(:user) do
user = create(:user)
user.update!(status: :trial)
user
end
it 'validates file size limit for large files' do
import = build(:import, user: user)
mock_file = double(attached?: true, blob: double(byte_size: 12.megabytes))
allow(import).to receive(:file).and_return(mock_file)
expect(import).not_to be_valid
expect(import.errors[:file]).to include('is too large. Trial users can only upload files up to 10MB.')
end
it 'allows files under the size limit' do
import = build(:import, user: user)
mock_file = double(attached?: true, blob: double(byte_size: 5.megabytes))
allow(import).to receive(:file).and_return(mock_file)
expect(import).to be_valid
end
end
context 'when user is a paid user' do
let(:user) { create(:user, status: :active) }
let(:import) { build(:import, user: user) }
it 'does not validate file size limit' do
allow(import).to receive(:file).and_return(double(attached?: true, blob: double(byte_size: 12.megabytes)))
expect(import).to be_valid
end
end
end
end end
describe 'enums' do describe 'enums' do

View file

@ -288,6 +288,8 @@ RSpec.describe User, type: :model do
let(:user) { create(:user, status: :active, active_until: 1000.years.from_now) } let(:user) { create(:user, status: :active, active_until: 1000.years.from_now) }
it 'returns false' do it 'returns false' do
user.update(status: :active)
expect(user.can_subscribe?).to be_falsey expect(user.can_subscribe?).to be_falsey
end end
end end
@ -304,6 +306,14 @@ RSpec.describe User, type: :model do
expect(user.can_subscribe?).to be_truthy expect(user.can_subscribe?).to be_truthy
end end
end end
context 'when user is on trial' do
let(:user) { create(:user, :trial, active_until: 1.week.from_now) }
it 'returns true' do
expect(user.can_subscribe?).to be_truthy
end
end
end end
end end

View file

@ -203,6 +203,16 @@ RSpec.describe 'Imports', type: :request do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end end
context 'when user is a trial user' do
let(:user) { create(:user, status: :trial) }
it 'returns http success' do
get new_import_path
expect(response).to have_http_status(200)
end
end
end end
end end