added 260907 version of worker toolkit

This commit is contained in:
2026-09-08 20:30:51 -04:00
parent cbd3f0f8ca
commit 97aca37663
1283 changed files with 142951 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
class Ability
include CanCan::Ability
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def initialize(user)
user ||= User.new # guest user (not logged in)
can :manage, User, id: user.id
can :manage, Profile, user_id: user.id
can :show, Condition
can :index, Condition, global: true
cannot :index, Condition, global: false
can :index, Condition, global: false, id: user.trackings.where(trackable_type: "Condition").pluck(:trackable_id)
can :read, Condition, global: false, id: popular_trackable_ids("Condition")
can :create, Condition, global: false
can :manage, Condition, id: user.condition_ids
can :show, Food
can :index, Food, global: true
cannot :index, Food, global: false
can :index, Food, global: false, id: user.food_ids
can :create, Food, global: false
can :manage, Food, id: user.food_ids
can :read, HarveyBradshawIndex, encrypted_user_id: user.encrypted_id
can :create, HarveyBradshawIndex do |hbi|
checkin = hbi.checkin
checkin.encrypted_user_id == user.encrypted_id && checkin.available_for_hbi?
end
can :read, PromotionRate, encrypted_user_id: user.encrypted_id
can [:create, :update], PromotionRate do |rate|
checkin = rate.checkin
checkin.encrypted_user_id == user.encrypted_id && checkin.available_for_promotion?
end
can [:create], Pattern
can [:read, :update, :destroy], Pattern, encrypted_user_id: user.encrypted_id
can [:read], ChartsPattern
can :read, [Comment, Post]
can :create, [Comment, Post], encrypted_user_id: user.encrypted_id
can :read, Notification
can :destroy, Notification, encrypted_notify_user_id: user.encrypted_id
can :update, Notification, encrypted_notify_user_id: user.encrypted_id
can :read, Postable, encrypted_user_id: user.encrypted_id
can :read, Reaction
can [:create, :update, :destroy], Reaction, encrypted_user_id: user.encrypted_id
can :show, Symptom
can :index, Symptom, global: true
cannot :index, Symptom, global: false
can :index, Symptom, global: false, id: user.trackings.where(trackable_type: "Symptom").pluck(:trackable_id)
can :read, Symptom, global: false, id: popular_trackable_ids("Symptom")
can :create, Symptom, global: false
can :manage, Symptom, id: user.symptom_ids
can [:read, :update], TopicFollowing, encrypted_user_id: user.encrypted_id
can :show, Treatment
can :index, Treatment, global: true
cannot :index, Treatment, global: false
can :index, Treatment, global: false, id: user.trackings.where(trackable_type: "Treatment").pluck(:trackable_id)
can :read, Symptom, global: false, id: popular_trackable_ids("Treatment")
can :create, Treatment, global: false
can :manage, Treatment, id: user.treatment_ids
can :manage, Tracking, user_id: user.id
can :show, Tag
can :index, Tag, global: true
cannot :index, Tag, global: false
can :index, Tag, global: false, id: user.tag_ids
can :create, Tag, global: false
can :manage, Tag, id: user.tag_ids
can :read, Weather if user.persisted?
can :read, NilClass if user.persisted?
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def popular_trackable_ids(trackable_class_name)
user_trackable_class = "User#{trackable_class_name}".constantize
trackable_id_attr = "#{trackable_class_name.underscore}_id".to_sym
counts = user_trackable_class.group(trackable_id_attr).count
min_popularity = Flaredown.config.trackables_min_popularity
counts.select { |_id, count| count >= min_popularity }.keys
end
end

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end

View File

@@ -0,0 +1,38 @@
class Chart
include ActiveModel::Serialization
include ActiveModel::Model
attr_accessor :id, :user, :start_at, :end_at, :includes
#
# Validations
#
validates :user, :start_at, :end_at, presence: true
def checkins
@checkins ||= user.checkins.by_date(start_at.to_date, end_at.to_date).map do |checkin|
checkin.includes = includes
checkin
end
end
def trackables
@_trackables ||= trackings.map(&:trackable)
end
private
def trackings
return Tracking.none if includes.blank?
relation = Tracking.includes(:trackable)
relation
.where(trackable_type: :Condition, trackable_id: includes[:conditions] || [])
.or(relation.where(trackable_type: :Symptom, trackable_id: includes[:symptoms] || []))
.or(relation.where(trackable_type: :Treatment, trackable_id: includes[:treatments] || []))
.where(user_id: user.id)
.active_in_range(start_at.to_date, end_at.to_date)
end
end

View File

@@ -0,0 +1,165 @@
class Checkin
include Mongoid::Document
HBI_PERIODICITY = 7
PR_PERIODICITY = 7
PR_START_FROM = 7
attr_accessor :includes
FIELD_TYPE = %w[tag food].freeze
#
# Fields
#
field :date, type: DateTime
field :food_ids, type: Array, default: []
field :note, type: String
field :postal_code, type: String
field :tag_ids, type: Array
field :weather_id, type: Integer
field :encrypted_user_id, type: String, encrypted: {type: :integer}
field :position_id, type: Integer
field :promotion_skipped_at, type: Date
#
# Relations
#
has_one :harvey_bradshaw_index
has_one :promotion_rate, dependent: :destroy
has_many :treatments, class_name: "Checkin::Treatment"
has_many :conditions, class_name: "Checkin::Condition"
has_many :symptoms, class_name: "Checkin::Symptom"
accepts_nested_attributes_for :conditions, :symptoms, :treatments, allow_destroy: true
#
# Indexes
#
index(encrypted_user_id: 1)
index(date: 1, encrypted_user_id: 1)
#
# Validations
#
validates :encrypted_user_id, presence: true
validates :date, presence: true
#
# Scopes
#
scope :by_date, ->(startkey, endkey) { where(:date.gte => startkey, :date.lte => endkey) }
def user
@user ||= User.find(user_id)
end
def weather
@weather ||= Weather.find_by(id: weather_id)
end
def position
@position ||= Position.find_by(id: position_id)
end
def tags
if includes
@_tags_included ||= Tag.where(id: tag_ids - (includes[:tags] || []))
else
@_tags ||= Tag.where(id: tag_ids)
end
end
def foods
if includes
@_foods_included ||= Food.where(id: food_ids - (includes[:foods] || []))
else
@_foods ||= Food.where(id: food_ids)
end
end
def available_for_hbi?
return true if harvey_bradshaw_index
return true unless latest_hbi
HBI_PERIODICITY - ((latest_hbi.date)...date).count < 1
end
def available_for_promotion?
return true if promotion_rate
return false if user_has_already_rated?
return false unless date.today?
return ready_for_promotion? if latest_skipped_pr_at.blank?
PR_PERIODICITY - ((latest_skipped_pr_at)...date).count < 1
end
class Condition
include Mongoid::Document
include Checkin::Trackable
include Checkin::Fiveable
field :condition_id, type: Integer
validates :condition_id, uniqueness: {scope: :checkin_id}
end
class Symptom
include Mongoid::Document
include Checkin::Trackable
include Checkin::Fiveable
field :symptom_id, type: Integer
validates :symptom_id, uniqueness: {scope: :checkin_id}
end
class Treatment
include Mongoid::Document
include Checkin::Trackable
#
# Fields
#
field :treatment_id, type: Integer
field :value, type: String
field :is_taken, type: Boolean
#
# Indexes
#
index(treatment_id: 1)
index(treatment_id: 1, is_taken: 1, value: 1)
#
# Validations
#
validates :treatment_id, uniqueness: {scope: :checkin_id}
end
def self.ids_by_category_attrs(category_name, trackable_id)
where(id: {"$in" => "Checkin::#{category_name.camelize}".constantize
.where(:"#{category_name}_id" => trackable_id, :value.ne => nil).pluck(:checkin_id)})
end
private
def latest_hbi
@_latest_hbi ||= HarveyBradshawIndex.where(encrypted_user_id: encrypted_user_id).order(date: :desc).first
end
def latest_skipped_pr_at
@_lates_skipped_pr ||=
Checkin
.where(encrypted_user_id: encrypted_user_id)
.order_by(promotion_skipped_at: :desc)
.first&.promotion_skipped_at
end
def ready_for_promotion?
user.created_at <= PR_START_FROM.day.ago
end
def user_has_already_rated?
@_rated ||= PromotionRate.where(encrypted_user_id: encrypted_user_id).any?
end
end

View File

@@ -0,0 +1,8 @@
module Checkin::Fiveable
extend ActiveSupport::Concern
included do
field :value, type: Integer
validates :value, inclusion: {in: (0..4), allow_blank: true}
end
end

View File

@@ -0,0 +1,16 @@
module Checkin::Trackable
extend ActiveSupport::Concern
included do
#
# Fields
#
field :position, type: Integer
field :color_id, type: String
#
# Relations
#
belongs_to :checkin, index: true
end
end

View File

@@ -0,0 +1,20 @@
class Comment
include Mongoid::Document
include Mongoid::Timestamps
include Usernameable
store_in collection: "postables"
field :body, type: String
field :_type, type: String, default: -> { self.class.name }
field :encrypted_user_id, type: String, encrypted: {type: :integer}
validates :body, :post, :encrypted_user_id, presence: true
belongs_to :post, index: true, counter_cache: true
has_many :reactions, as: :reactable, dependent: :destroy
has_many :notifications, as: :notificateable, dependent: :destroy
after_create { post.update(last_commented: Time.current) }
end

View File

@@ -0,0 +1,21 @@
module Authenticatable
extend ActiveSupport::Concern
included do
#
# Devise
#
devise :database_authenticatable,
:rememberable,
:recoverable,
:trackable,
:validatable,
:invitable,
:omniauthable
#
# Validates
#
validates :password, confirmation: true
end
end

View File

@@ -0,0 +1,35 @@
module Topicable
extend ActiveSupport::Concern
include Mongoid::Document
include Mongoid::Timestamps
TOPIC_TYPES = %w[tag symptom condition treatment].freeze
included do
field :tag_ids, type: Array, default: []
field :symptom_ids, type: Array, default: []
field :condition_ids, type: Array, default: []
field :treatment_ids, type: Array, default: []
field :encrypted_user_id, type: String, encrypted: {type: :integer}
validates :encrypted_user_id, presence: true
TOPIC_TYPES.each do |relative|
pluralized_relative = relative.pluralize
define_method(:"#{pluralized_relative}") do
ivar_name = :"@_#{pluralized_relative}"
value = instance_variable_get(ivar_name)
return value if value.present?
value = instance_variable_set(ivar_name, relative.classify.constantize.where(id: send("#{relative}_ids")))
value
end
end
end
end

View File

@@ -0,0 +1,7 @@
module Usernameable
def user_name
Profile.select(:screen_name).find_by!(user_id: SymmetricEncryption.decrypt(encrypted_user_id)).screen_name
rescue SymmetricEncryption::CipherError
""
end
end

View File

@@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: conditions
#
# id :integer not null, primary key
# global :boolean default(TRUE)
# created_at :datetime not null
# updated_at :datetime not null
# trackable_usages_count :integer default(0)
#
class Condition < ActiveRecord::Base
has_many :condition_translations, class_name: "Condition::Translation"
#
# Localized attributes
#
translates :name
end

View File

@@ -0,0 +1,15 @@
class DayHabit < RankedEnum
class << self
def all_ids
%w[lying_down sitting_down on_feet driving working_with_hands]
end
end
def name
I18n.t "#{key}.#{id}.name"
end
def description
I18n.t "#{key}.#{id}.description"
end
end

View File

@@ -0,0 +1,10 @@
class Dose
include ActiveModel::Serialization
include ActiveModel::Model
attr_accessor :name
def id
name.parameterize
end
end

View File

@@ -0,0 +1,7 @@
class EducationLevel < RankedEnum
class << self
def all_ids
%w[less_than_hi_school hi_school college_degree bachelors_degree advanced_degree]
end
end
end

View File

@@ -0,0 +1,8 @@
class Ethnicity < RankedEnum
class << self
def all_ids
%w[ latino white east_asian south_asian black oceanian
middle_eastern native_american african other not_sure ]
end
end
end

View File

@@ -0,0 +1,20 @@
class Feedback
include Mongoid::Document
include Mongoid::Timestamps
#
# Fields
#
field :email, type: String
field :delete_reason, type: String
#
# Indexes
#
index(date: 1, encrypted_user_id: 1)
#
# Validations
#
validates :email, :delete_reason, presence: true
end

View File

@@ -0,0 +1,54 @@
class Food < ActiveRecord::Base
LANG_MAP = {
en: :english,
it: :italian
}.freeze
has_many :food_translations, class_name: "Food::Translation"
translates :long_desc, :shrt_desc, :comname, :sciname
alias_method :name, :long_desc
class << self
def fts(query, limit, user_id)
find_by_sql(
[
fts_sql,
{
user_id: user_id,
lang: LANG_MAP[I18n.locale] || :simple,
query: query.strip.split(/(\s*,\s*)|\s+/).map { |s| "#{s}:*" }.join("&"),
limit: limit,
locale: I18n.locale
}
]
)
end
private
def fts_sql
<<-SQL.strip_heredoc
SELECT id, long_desc, searchable, global
FROM (
SELECT
to_tsvector(:lang, ft.long_desc) AS searchable,
foods.id AS id,
foods.global AS global,
ft.long_desc AS long_desc
FROM foods
INNER JOIN food_translations AS ft ON ft.food_id = foods.id
WHERE ft.locale = :locale AND foods.id IN (
SELECT uf.food_id
FROM user_foods AS uf
WHERE uf.user_id = :user_id
) OR foods.global IS TRUE
) f
WHERE f.searchable @@ to_tsquery(:lang, :query)
ORDER BY ts_rank_cd(f.searchable, to_tsquery(:lang, :query), 1) DESC
LIMIT :limit
SQL
end
end
end

View File

@@ -0,0 +1,69 @@
class HarveyBradshawIndex
include Mongoid::Document
#
# Fields
#
field :date, type: Date
field :score, type: Integer
field :stools, type: Integer
field :well_being, type: Integer
field :abdominal_mass, type: Integer
field :abdominal_pain, type: Integer
field :abscess, type: Boolean
field :uveitis, type: Boolean
field :arthralgia, type: Boolean
field :new_fistula, type: Boolean
field :anal_fissure, type: Boolean
field :aphthous_ulcers, type: Boolean
field :erythema_nodosum, type: Boolean
field :pyoderma_gangrenosum, type: Boolean
field :encrypted_user_id, type: String, encrypted: {type: :integer}
#
# Relations
#
belongs_to :checkin, index: true
#
# Indexes
#
index(date: 1, encrypted_user_id: 1)
#
# Validations
#
validates :checkin, :stools, :well_being, :abdominal_mass, :abdominal_pain, presence: true
validates :checkin_id, uniqueness: true
#
# Callbacks
#
before_save :calculate_score
before_create :set_date_and_user_id
private
def set_date_and_user_id
self.date = checkin.date
self.encrypted_user_id = checkin.encrypted_user_id
end
def calculate_score
self.score = stools + well_being + abdominal_mass + abdominal_pain
self.score += [
abscess,
uveitis,
arthralgia,
new_fistula,
anal_fissure,
aphthous_ulcers,
erythema_nodosum,
pyoderma_gangrenosum
].count { |v| v }
end
end

View File

@@ -0,0 +1,27 @@
class Invitation
include ActiveModel::Serialization
attr_reader :id, :email, :user
def initialize(id, user)
@id = id
@user = user
@email = user.email
end
def accept!(params = {})
User.accept_invitation!({invitation_token: id}.merge(params))
end
def self.find(id)
# FIXME
# rubocop:disable Rails/DynamicFindBy
user = User.find_by_invitation_token(id, true)
# rubocop:enable Rails/DynamicFindBy
# FIXME
# rubocop:disable Style/SignalException
fail ActiveRecord::RecordNotFound if user.nil?
# rubocop:enable Style/SignalException
new(id, user)
end
end

View File

@@ -0,0 +1,84 @@
class Notification
include Mongoid::Document
include Mongoid::Timestamps
include Usernameable
after_initialize :set_defaults
field :kind, type: String
field :encrypted_user_id, type: String, encrypted: {type: :integer}
field :encrypted_notify_user_id, type: String, encrypted: {type: :integer}
field :delivered, type: Boolean, default: false
field :post_id, type: String
field :unread, type: Boolean, default: true
validates :notificateable, :encrypted_user_id, :encrypted_notify_user_id, presence: true
belongs_to :notificateable, polymorphic: true
index notificateable_id: 1, notificateable_type: 1
def set_defaults
assign_attributes(post_id: ((notificateable._type == "Comment") ? notificateable.post_id : notificateable.id).to_s)
end
class << self
def aggregated_by_kind_and_subject
group = {}
each do |n|
key = [n.kind, n.notificateable_id.to_s, n.notificateable_type.downcase]
group[key] ||= []
group[key] << n
end
normalized_notifications(group)
end
def count_by_types
group = criteria.group(_id: "$kind", count: {"$sum": 1})
aggregate = collection.aggregate(group.pipeline)
aggregate.reduce({}) { |acc, notification|
h = {notification["_id"] => notification["count"]}
acc.merge(h)
}
end
def groupped_by_post_and_kind(encrypted_user_id)
where(encrypted_notify_user_id: encrypted_user_id)
.group_by(&:post_id).deep_transform_keys!(&:to_s)
.each_with_object({}) { |obj, hash| hash[obj[0]] = aggregate_group(obj[1].group_by(&:kind)) }
end
private
def normalized_notifications(group)
group.map do |group_keys, notifications|
notificateable = notifications.first.notificateable
is_comment = notificateable._type == "Comment"
{
id: [notifications.map(&:created_at).max.to_i].concat(group_keys).join("_"),
kind: group_keys.first,
count: notifications.count,
post_id: is_comment ? notificateable.post_id.to_s : group_keys[1],
post_title: (is_comment ? notificateable.post : notificateable)&.title,
notificateable_id: group_keys[1],
notificateable_type: group_keys.last,
unread: notifications.map(&:unread).last,
notifier_username: notifier_username(notifications)
}
end
end
def aggregate_group(groupped_by_kind)
groupped_by_kind.each_with_object({}) { |kind, hash| hash[kind[0]] = kind[1].count }
end
def notifier_username(notifications)
notifier_user_id = SymmetricEncryption.decrypt(notifications.first.encrypted_user_id)
Profile.find_by(user_id: notifier_user_id)&.screen_name
end
end
end

View File

@@ -0,0 +1,25 @@
class OracleRequest
include Mongoid::Document
field :age, type: Integer
field :token, type: String
field :sex_id, type: String
field :responce, type: Array, default: []
field :symptom_ids, type: Array, default: []
index(token: 1)
def sex
@_sex ||= Sex.find(sex_id)
end
def symptoms
@_symptoms ||= Symptom.where(id: symptom_ids)
end
def can_edit?(oracle_token)
token == oracle_token
end
end

View File

@@ -0,0 +1,18 @@
class Pattern
include Mongoid::Document
include Mongoid::Timestamps
field :encrypted_user_id, type: String, encrypted: {type: :integer}
field :start_at, type: Date
field :end_at, type: Date
field :includes, type: Array, default: []
field :name, type: String
index(encrypted_user_id: 1)
validates :encrypted_user_id, presence: true
def author
@author ||= User.find_by(id: SymmetricEncryption.decrypt(encrypted_user_id))
end
end

View File

@@ -0,0 +1,24 @@
class Position < ActiveRecord::Base
validates :postal_code, presence: true
validate :geocoder_position_present
before_create :set_location_name
def set_location_name
state = geocoder_position.state || geocoder_position.province
self.location_name = [geocoder_position.city, state, geocoder_position.country].compact.join(", ")
self.latitude = geocoder_position.latitude
self.longitude = geocoder_position.longitude
end
protected
def geocoder_position_present
errors.add(:postal_code, "No coordinates found for postal_code #{postal_code}}") if geocoder_position.blank?
end
def geocoder_position
@geocoder_position ||= Geocoder.search(postal_code).first
end
end

View File

@@ -0,0 +1,63 @@
class Post
include Topicable
include Usernameable
attr_accessor :postable_id
store_in collection: "postables"
field :body, type: String
field :title, type: String
field :_type, type: String, default: -> { self.class.name }
field :comments_count, type: Integer, default: 0
field :last_commented, type: DateTime, default: -> { Time.current }
field :comment_reaction_count, type: Integer, default: 0
validates :body, :title, presence: true
validate :topic_presence
has_many :comments, dependent: :destroy
has_many :reactions, as: :reactable, dependent: :destroy
has_many :notifications, as: :notificateable, dependent: :destroy
index(body: "text", title: "text")
def update_counters
set comment_reaction_count: comments_count + reactions.count
end
def self.fts(q)
where("$text": {"$search": q, "$language": I18n.locale.to_s})
end
def self.by_followings(followings)
any_of(
{:tag_ids.in => followings.tag_ids},
{:symptom_ids.in => followings.symptom_ids},
{:condition_ids.in => followings.condition_ids},
{:treatment_ids.in => followings.treatment_ids} # rubocop:disable Style/BracesAroundHashParameters
)
end
def self.frequency_by(topic_following)
where(_type: "Post")
.by_followings(topic_following)
.each_with_object({}) do |post, hash|
result_count = 0
%w[symptom_ids condition_ids tag_ids treatment_ids].each do |trackable|
result_count += (post.send(trackable) & topic_following.send(trackable)).count
end
hash[post.id] = result_count
end
end
private
def topic_presence
TOPIC_TYPES.each { |topic| return true if send(topic.pluralize).any? }
errors.add(:topics, "should have at least one of associated #{TOPIC_TYPES.join(",")}")
end
end

View File

@@ -0,0 +1,3 @@
class Postable
include Mongoid::Document
end

View File

@@ -0,0 +1,168 @@
# == Schema Information
#
# Table name: profiles
#
# id :integer not null, primary key
# user_id :integer
# country_id :string
# birth_date :date
# sex_id :string
# onboarding_step_id :string
# created_at :datetime not null
# updated_at :datetime not null
# ethnicity_ids_string :string
# day_habit_id :string
# education_level_id :string
# day_walking_hours :integer
# most_recent_doses :hstore
# screen_name :string
# most_recent_conditions_positions :hstore
# most_recent_symptoms_positions :hstore
# most_recent_treatments_positions :hstore
#
class Profile < ActiveRecord::Base
enum pressure_units: %i[mb in]
enum temperature_units: %i[f c]
#
# Associations
#
belongs_to :user
delegate :email, to: :user
MIN_AGE = 16
#
# Validations
#
validates :country_id, inclusion: {
in: Country.codes
}, if: -> { country_id.present? }
validates :sex_id, inclusion: {
in: Sex.all_ids
}, if: -> { sex_id.present? }
validates :day_habit_id, inclusion: {
in: DayHabit.all_ids,
message: "%{value} is not a valid day_habit_id"
}, if: -> { day_habit_id.present? }
validates :education_level_id, inclusion: {
in: EducationLevel.all_ids,
message: "%{value} is not a valid education_level_id"
}, if: -> { education_level_id.present? }
validate :ethnicity_ids_are_valid, if: -> { ethnicity_ids_string.present? }
validate :age_of_birth, if: -> { birth_date.present? }
def ethnicity_ids_are_valid
ethnicity_ids.each do |id|
unless Ethnicity.all_ids.include?(id)
errors.add(:ethnicity_ids, "#{id} is not a valid ethnicity_id")
break
end
end
end
#
# Callbacks
#
before_create :generate_notify_token
before_save :ensure_slug_name, if: :screen_name_changed?
TIMEZONE_PARAMS = {
time: [20, 0],
time_zone_name: "America/New_York"
}.freeze
NOTIFICATION_ATTRS = %w[checkin_reminder notify_top_posts notify].freeze
#
# Instance Methods
#
def country
@country ||= Country.find_country_by_alpha2(country_id) if country_id.present?
end
def locale
country.languages.first if country.present?
end
def age
return if birth_date.nil?
today = Time.zone.today
age = today.year - birth_date.year
(today < birth_date + age.years) ? age - 1 : age
end
def ethnicity_ids
ethnicity_ids_string&.split(",") || []
end
def ethnicity_ids=(ids)
self.ethnicity_ids_string = ids.join(",")
end
def set_most_recent_dose(treatment_id, dose)
most_recent_doses[treatment_id.to_s] = dose
end
def most_recent_dose_for(treatment_id)
most_recent_doses[treatment_id.to_s]
end
def set_most_recent_trackable_position(trackable, position)
trackable_type = trackable.class.name.underscore
send("set_most_recent_#{trackable_type}_position", trackable, position)
end
def most_recent_trackable_position_for(trackable)
trackable_type = trackable.class.name.underscore
send("most_recent_#{trackable_type}_position_for", trackable)
end
%w[condition symptom treatment].each do |trackable_type|
define_method "set_most_recent_#{trackable_type}_position" do |trackable, position|
send(
"most_recent_#{trackable_type.pluralize}_positions"
)[trackable.id.to_s] = position.to_s
end
define_method "most_recent_#{trackable_type}_position_for" do |trackable|
trackables_positions = send("most_recent_#{trackable_type.pluralize}_positions")
return nil if trackables_positions.blank?
trackables_positions[trackable.id.to_s].to_i
end
end
private
def generate_notify_token
self.notify_token =
loop do
random_token = SecureRandom.hex
break random_token unless Profile.exists?(notify_token: random_token)
end
end
def ensure_slug_name
slug_name = screen_name && screen_name.split(/\s/).map(&:capitalize).join("")
assign_attributes(slug_name: slug_name)
end
def age_of_birth
if age < MIN_AGE
errors.add(:birth_date, "You must be #{MIN_AGE} years or older to use this app according
to international privacy regulations. Sorry :(")
end
end
end

View File

@@ -0,0 +1,66 @@
class PromotionRate
include Mongoid::Document
include Mongoid::Timestamps
MIN_SCORE_FOR_REVIWS = 8
field :date, type: Date
field :score, type: Integer
field :feedback, type: String
field :encrypted_user_id, type: String, encrypted: {type: :integer}
field :user_created_at, type: Date
belongs_to :checkin, index: true
index(date: 1, encrypted_user_id: 1)
validates :checkin, presence: true
validates :checkin_id, uniqueness: true
before_create :set_date_and_user_id
# '17-09-2017'
def self.group_by_score_and_date(start_str, end_str)
start_day = start_str.to_date
end_day = end_str.to_date
PromotionRate.collection.aggregate(
[
{"$match": {
date: {
"$gte": start_day,
"$lte": end_day
}
}},
"$group": {_id: "$score", count: {"$sum": 1}}
]
)
end
# '17-09-2017'
def self.filter_low_scores_by_date(start_str, end_str)
start_day = start_str.to_date
end_day = end_str.to_date
PromotionRate.collection.aggregate(
[
{"$match": {
score: {
"$lte": MIN_SCORE_FOR_REVIWS
},
date: {
"$gte": start_day,
"$lte": end_day
}
}}
]
)
end
protected
def set_date_and_user_id
self.date = checkin.date
self.encrypted_user_id = checkin.encrypted_user_id
end
end

View File

@@ -0,0 +1,39 @@
class RankedEnum
include ActiveModel::Serialization
attr_accessor :id, :rank
def initialize(id, rank)
@id = id
@rank = rank
end
class << self
def all_ids
[]
end
def find(id)
all.find { |obj| obj.id.eql?(id) }
end
def all
Rails.cache.fetch(name.pluralize.underscore) do
result = []
all_ids.each_with_index { |id, i| result << new(id, i + 1) }
result
end
end
end
def name
I18n.t! "#{key}.#{id}"
rescue I18n::MissingTranslationData
# This avoids strings in the UI like "translation missing: pt.sex.male"
I18n.t "#{key}.#{id}", locale: :en
end
def key
self.class.name.underscore
end
end

View File

@@ -0,0 +1,36 @@
class Reaction
include Mongoid::Document
field :value, type: String
field :encrypted_user_id, type: String, encrypted: {type: :integer}
validates :value, :reactable, :encrypted_user_id, presence: true
belongs_to :reactable, polymorphic: true
class << self
def similar_to(reaction)
where(
value: reaction.value,
reactable_id: reaction.reactable_id,
reactable_type: reaction.reactable_type
)
end
def values_count_with_participated(encrypted_user_id)
reactions_for_user = where(encrypted_user_id: encrypted_user_id).pluck(:value, :_id).to_h
aggregation = criteria.group(:_id => "$value", :count.sum => 1)
collection.aggregate(aggregation.pipeline).map do |reaction|
# A real ID is only required when the current user has reacted. Otherwise the value is meaningless
# Use a random string to try to demonstrate that
{
id: (reactions_for_user[reaction["_id"]] || SecureRandom.hex(16)).to_s,
value: reaction["_id"],
count: reaction["count"],
participated: reactions_for_user.key?(reaction["_id"])
}
end
end
end
end

View File

@@ -0,0 +1,77 @@
class Registration
include ActiveModel::Validations
include ActiveModel::Serialization
attr_accessor :user, :errors, :captcha_response, :screen_name, :birth_date
validates :captcha_response, :screen_name, presence: true
validate :captcha_response_verified
def initialize(params)
@user_params = permitted(params).to_hash
@screen_name = @user_params.delete("screen_name")
@birth_date = @user_params.delete("birth_date")
@captcha_response = @user_params.delete("captcha_response")
@errors = ActiveModel::Errors.new(self)
end
def save!
# FIXME
# rubocop:disable Style/SignalException
fail ActiveRecord::RecordInvalid, self unless valid?
# rubocop:enable Style/SignalException
begin
@user = User.create!(@user_params)
profile = @user.profile
profile.birth_date = birth_date
profile.screen_name = screen_name if screen_name.present?
profile.save!
rescue ActiveRecord::RecordInvalid => e
self.errors = e.record.errors
raise ActiveRecord::RecordInvalid, self
end
self
end
def self.create!(params)
new(params).save!
end
def self.delete!(params)
email = params[:email]
user = User.find_by(email: email)
return if user.blank?
ActiveRecord::Base.transaction do
user.profile.destroy!
user.checkins.or({:postal_code.ne => nil}, :position_id.ne => nil)
&.update_all(postal_code: nil, position_id: nil)
user.destroy!
Feedback.create!(email: email, delete_reason: params[:delete_reason])
end
end
# Use AR's i18n scope so that we can raise RecordInvalid exceptions
def self.i18n_scope
:activerecord
end
private
def permitted(params)
params.require(:registration).permit(
:email, :password, :password_confirmation, :screen_name, :captcha_response, :birth_date
)
end
def captcha_response_verified
verified = ::Google::RecaptchaVerifier.exec(captcha_response)
errors.add(:captcha_response, "verification failed or response expired") unless verified
end
end

View File

@@ -0,0 +1,71 @@
class Search
include ActiveRecord::Sanitization::ClassMethods
include ActiveModel::Serialization
include ActiveModel::Model
attr_accessor :resource, :scope, :user, :query
#
# Validations
#
validates :resource, presence: true, inclusion: {in: %w[treatment symptom condition tag dose food topic]}
def id
Digest::SHA256.base64digest(resource)
end
def searchables
# FIXME
# rubocop:disable Rails/DynamicFindBy
@searchables ||= find_by_query
# rubocop:enable Rails/DynamicFindBy
end
MAX_ROWS = 10
protected
def query_hash
@query_hash ||= Hash(query)
end
def find_by_query
if query_hash.present?
resources.where(*where_conditions).limit(MAX_ROWS)
elsif scope.present?
case scope
when "random"
resources.limit(MAX_ROWS).order("RANDOM()")
end
else
resources.limit(MAX_ROWS)
end
end
private
def where_conditions
@where_conditions ||=
[].tap do |conditions|
query_hash.each do |key, value|
sanitized_value = sanitize_sql_like(value.squish.downcase)
pattern =
if sanitized_value.length < 3
"#{sanitized_value}%"
else
"%#{sanitized_value}%"
end
conditions << ["lower(#{key}) LIKE ?", pattern]
end
end
end
def resources
resource.classify.constantize.accessible_by(current_ability).with_translations(I18n.locale)
end
def current_ability
@current_ability ||= Ability.new(user)
end
end

View File

@@ -0,0 +1,35 @@
class Search::ForDose < ::Search
validates :query, presence: true
validate :query_must_include_treatment_id
def query_must_include_treatment_id
return if query[:treatment_id]
errors.add(:query, "must include treatment_id")
end
protected
def find_by_query
q = name.present? ? treatments.where(value: /#{name}/i) : treatments
q = q.order_by("checkin.date" => "desc").distinct(:value)
q.slice(0..10).map { |value| ::Dose.new(name: value) }
end
private
def treatments
Checkin::Treatment.where(
:value.ne => nil, :is_taken => true, :treatment_id => treatment_id
)
end
def treatment_id
query[:treatment_id].to_i
end
def name
text = query[:name]
Regexp.escape(text) if text
end
end

View File

@@ -0,0 +1,9 @@
class Search::ForFood < ::Search
validates :query, presence: true
protected
def find_by_query
Food.fts(query[:name], MAX_ROWS, user.id)
end
end

View File

@@ -0,0 +1,19 @@
class Search::ForTopic < ::Search
alias_method :super_query, :find_by_query
protected
def find_by_query
(tags + symptoms + conditions + treatments).sort { |a, b| a.name.underscore <=> b.name.underscore }
end
private
%w[tag symptom condition treatment].each do |trackable|
define_method trackable.pluralize do
@resource = trackable
super_query
end
end
end

View File

@@ -0,0 +1,7 @@
class Sex < RankedEnum
class << self
def all_ids
%w[male female other doesnt_say]
end
end
end

View File

@@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: symptoms
#
# id :integer not null, primary key
# global :boolean default(TRUE)
# created_at :datetime not null
# updated_at :datetime not null
# trackable_usages_count :integer default(0)
#
class Symptom < ActiveRecord::Base
has_many :symptom_translations, class_name: "Symptom::Translation"
#
# Localized attributes
#
translates :name
end

View File

@@ -0,0 +1,17 @@
# == Schema Information
#
# Table name: tags
#
# id :integer not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
#
class Tag < ActiveRecord::Base
has_many :tag_translations, class_name: "Tag::Translation"
#
# Localized attributes
#
translates :name
end

View File

@@ -0,0 +1,11 @@
class TopicFollowing
include Topicable
def add_topic(key, value)
return if send(key).include?(value)
send(key) << value
save
end
end

View File

@@ -0,0 +1,46 @@
# == Schema Information
#
# Table name: trackable_usages
#
# id :integer not null, primary key
# user_id :integer
# trackable_id :integer
# trackable_type :string
# count :integer default(1)
# created_at :datetime not null
# updated_at :datetime not null
#
class TrackableUsage < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :trackable, polymorphic: true, counter_cache: true
#
# Validations
#
validates :count, numericality: {greater_than: 0}
#
# Callbacks
#
after_commit ->(obj) { SwitchTrackableVisibility.perform_later(obj.id) }
#
# Class Methods
#
class << self
def create_or_update_by(user: nil, trackable: nil)
trackable_usage = find_by(user: user, trackable: trackable)
if trackable_usage.present?
trackable_usage.increment! :count
trackable_usage
else
create!(user: user, trackable: trackable)
end
end
end
end

View File

@@ -0,0 +1,63 @@
# == Schema Information
#
# Table name: trackings
#
# id :integer not null, primary key
# user_id :integer
# trackable_id :integer
# trackable_type :string
# start_at :date
# end_at :date
# color_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class Tracking < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :trackable, polymorphic: true
#
# Validations
#
TYPES = %w[Condition Symptom Treatment].freeze
validates :user, :trackable, :start_at, presence: true
validates :trackable_type, inclusion: {
in: TYPES
}
validates :user_id, uniqueness: {
scope: %i[trackable_id trackable_type start_at], message: "is already tracking this trackable"
}
#
# Scopes
#
scope :active_at, ->(at) { where("start_at <= :at AND (end_at >= :at OR end_at IS NULL)", at: at.strftime("%F")) }
scope :by_trackable_type, ->(trackable_type) { where(trackable_type: trackable_type) }
#
# Callbacks
#
before_create :ensure_color_id
def self.active_in_range(range_start, range_end)
where(
"(start_at <= :range_start OR start_at <= :range_end) AND (end_at > :range_start OR end_at IS NULL)",
range_start: range_start.strftime("%F"),
range_end: range_end.strftime("%F")
)
end
def ensure_color_id
return if color_id
self.color_id = Flaredown::Colorable.color_id_for(trackable, user)
end
end

View File

@@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: treatments
#
# id :integer not null, primary key
# global :boolean default(TRUE)
# created_at :datetime not null
# updated_at :datetime not null
# trackable_usages_count :integer default(0)
#
class Treatment < ActiveRecord::Base
has_many :treatment_translations, class_name: "Treatment::Translation"
#
# Localized attributes
#
translates :name
end

View File

@@ -0,0 +1,133 @@
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# email :string default(""), not null
# encrypted_password :string default(""), not null
# reset_password_token :string
# reset_password_sent_at :datetime
# remember_created_at :datetime
# sign_in_count :integer default(0), not null
# current_sign_in_at :datetime
# last_sign_in_at :datetime
# current_sign_in_ip :string
# last_sign_in_ip :string
# authentication_token :string not null
# invitation_token :string
# invitation_created_at :datetime
# invitation_sent_at :datetime
# invitation_accepted_at :datetime
# invitation_limit :integer
# invited_by_id :integer
# invited_by_type :string
# created_at :datetime not null
# updated_at :datetime not null
#
class User < ActiveRecord::Base
include Authenticatable
#
# Associations
#
has_one :profile, dependent: :destroy
has_many :user_conditions, dependent: :destroy
has_many :conditions, through: :user_conditions
has_many :user_symptoms, dependent: :destroy
has_many :symptoms, through: :user_symptoms
has_many :user_treatments, dependent: :destroy
has_many :treatments, through: :user_treatments
has_many :user_tags, dependent: :destroy
has_many :tags, through: :user_tags
has_many :user_foods, dependent: :destroy
has_many :foods, through: :user_foods
has_many :trackings, dependent: :destroy
has_many :trackable_usages, dependent: :destroy
#
# Callbacks
#
before_create :generate_authentication_token
after_create :init_profile
#
# Delegates
#
delegate :locale, :notify, :notify_token, :screen_name, :rejected_type, to: :profile
def checkins
Checkin.where(encrypted_user_id: encrypted_id)
end
def patterns
Pattern.where(encrypted_user_id: encrypted_id)
end
def encrypted_id
@_encrypted_id ||= SymmetricEncryption.encrypt(id)
end
def last_checkin
checkins.order_by(date: :desc).first
end
def checkin_ids
checkins.map(&:id)
end
def notification_channel
"private-#{id}"
end
def push(event, message)
Flaredown.pusher.push(notification_channel, event, message)
end
def external_id
"#{id}-#{Digest::SHA1.hexdigest(authentication_token).strip}"
end
def topic_following
TopicFollowing.find_or_create_by(encrypted_user_id: encrypted_id) do |tf|
checkin = last_checkin
next if checkin.blank?
tf.symptom_ids = Checkin::Symptom.where(checkin_id: checkin.id).distinct(:symptom_id)
tf.condition_ids = Checkin::Condition.where(checkin_id: checkin.id).distinct(:condition_id)
tf.treatment_ids = Checkin::Treatment.where(checkin_id: checkin.id).distinct(:treatment_id)
tf.tag_ids = checkin.tag_ids || []
end
end
private
def generate_authentication_token
self.authentication_token =
loop do
random_token = SecureRandom.hex
break random_token unless User.exists?(authentication_token: random_token)
end
end
def init_profile
create_profile!(
checkin_reminder: true,
onboarding_step_id: "onboarding-personal",
most_recent_doses: {},
most_recent_conditions_positions: {},
most_recent_symptoms_positions: {},
most_recent_treatments_positions: {}
)
end
end

View File

@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: user_conditions
#
# id :integer not null, primary key
# user_id :integer
# condition_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class UserCondition < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :condition
#
# Validations
#
validates :user, :condition, presence: true
end

View File

@@ -0,0 +1,12 @@
class UserFood < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :food
#
# Validations
#
validates :user, :food, presence: true
end

View File

@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: user_symptoms
#
# id :integer not null, primary key
# user_id :integer
# symptom_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class UserSymptom < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :symptom
#
# Validations
#
validates :user, :symptom, presence: true
end

View File

@@ -0,0 +1,12 @@
class UserTag < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :tag
#
# Validations
#
validates :user, :tag, presence: true
end

View File

@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: user_treatments
#
# id :integer not null, primary key
# user_id :integer
# treatment_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class UserTreatment < ActiveRecord::Base
#
# Associations
#
belongs_to :user
belongs_to :treatment
#
# Validations
#
validates :user, :treatment, presence: true
end

View File

@@ -0,0 +1,5 @@
class Weather < ActiveRecord::Base
validates :date, uniqueness: {scope: :position_id}
# NOTE: if we want to enforce presence, modify spec factory instead
belongs_to :position, optional: true
end