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/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.30.9] - 2025-08-11
# [0.30.9] - 2025-08-13
## 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.
- Trial version for cloud users is now available.
# [0.30.8] - 2025-08-01

View File

@@ -59,11 +59,11 @@ class StatsController < ApplicationController
@stats.each do |year, stats|
stats_by_month = stats.index_by(&:month)
year_distances[year] = (1..12).map do |month|
month_name = Date::MONTHNAMES[month]
distance = stats_by_month[month]&.distance || 0
[month_name, distance]
end
end

View File

@@ -5,7 +5,8 @@ import { showFlashMessage } from "../maps/helpers"
export default class extends Controller {
static targets = ["input", "progress", "progressBar", "submit", "form"]
static values = {
url: String
url: String,
userTrial: Boolean
}
connect() {
@@ -50,6 +51,22 @@ export default class extends Controller {
const files = this.inputTarget.files
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`)
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)
user = User.find(user_id)
token = Subscription::EncodeJwtToken.new(
{ user_id: user.id, email: user.email, action: 'create_user' },
ENV['JWT_SECRET_KEY']
).call
payload = {
user_id: user.id,
email: user.email,
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"
headers = {

View File

@@ -13,6 +13,7 @@ class Import < ApplicationRecord
after_commit :remove_attached_file, on: :destroy
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 }
@@ -22,6 +23,18 @@ class Import < ApplicationRecord
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!
if user_data_archive?
process_user_data_archive!

View File

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

View File

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

View File

@@ -56,7 +56,12 @@ class Imports::Create
end
def schedule_visit_suggesting(user_id, import)
return unless user.safe_settings.visits_suggestions_enabled?
points = import.points.order(:timestamp)
return if points.none?
start_at = Time.zone.at(points.first.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>
<code><%= current_user.api_key %></code>
<%# if ENV['QR_CODE_ENABLED'] == 'true' %>
<% if ENV['QR_CODE_ENABLED'] == 'true' %>
<p class='py-2'>
Or you can scan it in your Dawarich iOS app:
<%= api_key_qr_code(current_user) %>
</p>
<%# end %>
<% end %>
<p class='py-2'>
<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: {
controller: "direct-upload",
direct_upload_url_value: rails_direct_uploads_url,
direct_upload_user_trial_value: current_user.trial?,
direct_upload_target: "form"
} do |form| %>
<div class="form-control w-full">

View File

@@ -1,16 +1,21 @@
<dialog id="getting_started" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Start tracking your location!</h3>
<p class="py-4">
To start tracking your location and putting it on the map, you need to configure your mobile application.
</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' %>.
</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
<% 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">
<h3 class="text-lg font-bold">Start tracking your location!</h3>
<p class="py-4">
To start tracking your location and putting it on the map, you need to configure your mobile application.
</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://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
</div>
</div>
</div>
</dialog>
</dialog>
</div>
<% end %>

View File

@@ -72,8 +72,7 @@
<div class="navbar-end">
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %>
<%# if current_user.can_subscribe? %>
<% if current_user.can_subscribe? %>
<div class="join">
<%= 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) %>">
@@ -83,7 +82,7 @@
</span>
<% end %>
</div>
<%# end %>
<% end %>
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
data-controller="notifications"
@@ -159,4 +158,3 @@
</ul>
</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>
</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>

View File

@@ -36,7 +36,7 @@
<li>Enjoy beautiful interactive maps</li>
</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>

View File

@@ -36,11 +36,11 @@
<li>Visit detection and places management</li>
</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>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>
Evgenii from Dawarich</p>

View File

@@ -28,9 +28,9 @@
<li>Export your data in various formats</li>
</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>
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
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!
Evgenii from Dawarich

View File

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

View File

@@ -1,9 +1,19 @@
# Preview all emails at http://localhost:3000/rails/mailers/users_mailer
class UsersMailerPreview < ActionMailer::Preview
# frozen_string_literal: true
# Preview this email at http://localhost:3000/rails/mailers/users_mailer/welcome
class UsersMailerPreview < ActionMailer::Preview
def welcome
UsersMailer.welcome
UsersMailer.with(user: User.last).welcome
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

View File

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

View File

@@ -3,16 +3,69 @@
require 'rails_helper'
RSpec.describe Import, type: :model do
let(:user) { create(:user) }
subject(:import) { create(:import, user:) }
describe 'associations' do
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
describe 'validations' do
subject { build(:import, name: 'test import') }
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
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) }
it 'returns false' do
user.update(status: :active)
expect(user.can_subscribe?).to be_falsey
end
end
@@ -304,6 +306,14 @@ RSpec.describe User, type: :model do
expect(user.can_subscribe?).to be_truthy
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

View File

@@ -203,6 +203,16 @@ RSpec.describe 'Imports', type: :request do
expect(response).to have_http_status(200)
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