An application that reminds you to water your plants

Modify data layers and data access. Add more functionality including setup

+4 -1
.rubocop.yml
··· 1 1 AllCops: 2 2 NewCops: enable 3 - TargetRubyVersion: 3.4 3 + TargetRubyVersion: 3.4 4 + 5 + Style/CharacterLiteral: 6 + Enabled: false
+16 -11
app.rb
··· 1 1 # frozen_string_literal: true 2 2 3 - require 'irb' 4 3 require 'sinatra' 5 4 6 5 require_relative 'lib/database' 7 6 require_relative 'lib/data_access' 7 + require_relative 'lib/services' 8 8 9 9 database = Database.new 10 10 data_access = DataAccess.new database 11 + services = Services.new data_access 11 12 12 - get '/' do 13 - erb :index 14 - end 13 + def simple_get(path, view)= get(path) { erb view.to_sym } 15 14 16 - get '/db-health-check' do 17 - results = database.exec 'SELECT NOW() AS time' 18 - results[0]['time'] 15 + # Simple routes 16 + simple_get '/', :index 17 + simple_get '/plants', :plants 18 + get('/db-health-check') { database.healthcheck } 19 + 20 + post '/new-plant' do 21 + redirect '/plants' if services.plant.new_plant user_id: params['user_id'], 22 + name: params['name'], 23 + plant_type: params['plant_type'] 19 24 end 20 25 21 26 post '/sign-up' do 22 - return data_access.users.sign_up! params['email'] if params.key?('email') 27 + return services.user.sign_up! params['email'] if params.key?('email') 23 28 24 29 status 400 25 30 'No email provided!' 26 31 end 27 32 28 33 post '/validate-code' do 29 - data_access.users.validate_code? ip: request.env['REMOTE_ADDR'], 30 - email: params['email'], 31 - code: params['validation-code'] 34 + redirect '/plants' if services.user.validate_code? ip: request.env['REMOTE_ADDR'], 35 + email: params['email'], 36 + code: params['validation-code'] 32 37 end 33 38 34 39 post '/send-email' do
+5 -1
bin/setup
··· 9 9 gem install bundler 10 10 fi 11 11 12 - bundle install 12 + psql -f "db/create_db.sql" 13 + 14 + for table in users validations plants; do 15 + psql -d plant_reminder_development -f "db/create_$table.sql" 16 + done
+2
config.ru
··· 1 + # frozen_string_literal: true 2 + 1 3 require_relative 'app' 2 4 3 5 run Sinatra::Application
+2
db/create_db.sql
··· 1 + SELECT 'CREATE DATABASE plant_reminder_development' 2 + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'plant_reminder_development')\gexec
+12
db/create_plants.sql
··· 1 + CREATE TABLE IF NOT EXISTS plant_types ( 2 + id SERIAL PRIMARY KEY NOT NULL, 3 + name VARCHAR(100) NOT NULL, 4 + watering_frequency_in_minutes INT 5 + ); 6 + 7 + CREATE TABLE IF NOT EXISTS plants ( 8 + id SERIAL PRIMARY KEY NOT NULL, 9 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 10 + name VARCHAR(200), 11 + plant_type_id INT REFERENCES plant_types(id) ON DELETE SET NULL 12 + );
+5
db/create_users.sql
··· 1 + CREATE TABLE IF NOT EXISTS users ( 2 + id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), 3 + email VARCHAR(100) UNIQUE NOT NULL, 4 + access_code VARCHAR(20) UNIQUE 5 + );
+7
db/create_validations.sql
··· 1 + CREATE TABLE IF NOT EXISTS validations ( 2 + id SERIAL PRIMARY KEY NOT NULL, 3 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 4 + ip_address CIDR, 5 + validated_at TIMESTAMP NOT NULL DEFAULT NOW(), 6 + validated BOOLEAN 7 + );
+3 -2
lib/data_access.rb
··· 1 1 # frozen_string_literal: true 2 2 3 3 require_relative 'data_access/users_data_access' 4 + require_relative 'data_access/validations_data_access' 4 5 5 6 # General class for all data_access classes 6 7 class DataAccess 7 - attr_reader :database, :users 8 + attr_reader :users, :validations 8 9 9 10 def initialize(database) 10 - @database = database 11 11 @users = UsersDataAccess.new database 12 + @validations = ValidationsDataAccess.new database 12 13 end 13 14 end
+70
lib/data_access/base_data_access.rb
··· 1 + # frozen_string_literal: false 2 + 3 + # Base data access layer 4 + class BaseDataAccess 5 + class << self 6 + attr_accessor :table_name 7 + end 8 + 9 + def initialize(database) 10 + @database = database 11 + end 12 + 13 + COLUMNS = [].freeze 14 + 15 + def find_or_create(*, **params) 16 + base_find(*, **params) do |result, params| 17 + next result if result.one? 18 + 19 + id = insert(**params) 20 + 21 + params[:id] = id 22 + 23 + find(*, **params) 24 + end 25 + end 26 + 27 + # Do not pass block 28 + def find(*, **params) = base_find(*, **params) 29 + 30 + def insert(**params) 31 + valid_params = params.select { |column| self.class::COLUMNS.include?(column) } 32 + insert_statement = build_insert_statement(valid_params.keys) 33 + database.exec(insert_statement, valid_params.values)[0]['id'] 34 + end 35 + 36 + attr_reader :database 37 + 38 + private 39 + 40 + def base_find(choose: [:id], **params) 41 + valid_choose = choose & self.class::COLUMNS # the intersection operator is great for things like this! 42 + query_by = filter_params_by_columns params 43 + query = build_find_query valid_choose, query_by 44 + result = database.exec query, [*query_by.values] 45 + return result unless block_given? 46 + 47 + yield result, params 48 + end 49 + 50 + def build_insert_statement(columns) 51 + "INSERT INTO #{self.class.table_name} (".tap do |sql| 52 + sql << columns.join(', ') 53 + sql << ') VALUES (' 54 + sql << (1..columns.length).map { |pos| "$#{pos}" }.join(', ') 55 + sql << ') RETURNING id' 56 + end 57 + end 58 + 59 + def build_find_query(valid_choose, query_by) 60 + 'SELECT '.tap do |sql| 61 + sql << valid_choose.join(', ') 62 + sql << " FROM #{self.class.table_name} WHERE " 63 + sql << query_by.map.with_index(1) { |(key), index| "#{key}=$#{index}" }.join(' AND ') 64 + end 65 + end 66 + 67 + def filter_params_by_columns(params) 68 + params.select { |column| self.class::COLUMNS.include?(column) } 69 + end 70 + end
+15
lib/data_access/plant_data_access.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'base_data_access' 4 + 5 + # Handles the data access for plants 6 + class PlantDataAccess < BaseDataAccess 7 + self.table_name = 'plants' 8 + 9 + COLUMNS = %i[id user_id name plant_type_id].freeze 10 + 11 + def insert(user_id:, name:, plant_type_id:) 12 + database.exec 'INSERT INTO plants (user_id, name, plant_type_id) VALUES ($1, $2, $3)', 13 + [user_id, name, plant_type_id] 14 + end 15 + end
+10
lib/data_access/plant_type_data_access.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'base_data_access' 4 + 5 + # Handles the data access for plant types 6 + class PlantTypeDataAccess < BaseDataAccess 7 + self.table_name = 'plant_types' 8 + 9 + COLUMNS = %i[id name watering_frequency_in_minutes].freeze 10 + end
+5 -24
lib/data_access/users_data_access.rb
··· 1 1 # frozen_string_literal: true 2 2 3 - require_relative '../databaseable' 3 + require_relative 'base_data_access' 4 4 5 5 # Handles the data access for users 6 - class UsersDataAccess 7 - include Databaseable 6 + class UsersDataAccess < BaseDataAccess 7 + self.table_name = 'users' 8 8 9 - COUNT_WITHIN_QUANTUM_SQL = <<~SQL 10 - SELECT COUNT(*) from validations 11 - WHERE user_id = $1 AND validated_at >= NOW() - INTERVAL '30 minutes' AND validated = false 12 - SQL 9 + COLUMNS = %i[id email access_code].freeze 13 10 14 - def sign_up!(email) 15 - raise 'Invalid email' unless /^[^@]+@[^.]+\.[^.]+$/.match? email 16 - 11 + def insert(email:) 17 12 database.exec 'INSERT INTO users (email) VALUES ($1)', [email] 18 - 'success' 19 - rescue PG::UniqueViolation 20 - "An account with the email #{email} already exists" 21 - end 22 - 23 - def validate_code?(code:, email:, ip:) 24 - user_id = database.exec('SELECT id FROM users WHERE email = $1', [email])[0]['id'] 25 - count_within_quantum = database.exec(COUNT_WITHIN_QUANTUM_SQL, [user_id]) 26 - raise 'Too many requests' if count_within_quantum[0]['count'].to_i >= 10 27 - 28 - database.exec 'INSERT INTO validations (user_id, ip_address) VALUES ($1, $2)', [user_id, ip] 29 - 30 - results = database.exec 'SELECT access_code FROM users WHERE id = $1', [user_id] 31 - results[0]['code'] == code 32 13 end 33 14 end
+23
lib/data_access/validations_data_access.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'base_data_access' 4 + 5 + # Handles the data access for validations 6 + class ValidationsDataAccess < BaseDataAccess 7 + self.table_name = 'validations' 8 + 9 + COLUMNS = %i[id user_id ip_address validated_at validated].freeze 10 + 11 + COUNT_WITHIN_QUANTUM_SQL = <<~SQL 12 + SELECT COUNT(*) from validations 13 + WHERE user_id = $1 AND validated_at >= NOW() - INTERVAL '30 minutes' AND validated = false 14 + SQL 15 + 16 + def validations_within_time_quantum_for_user_id(user_id) 17 + database.exec COUNT_WITHIN_QUANTUM_SQL, [user_id] 18 + end 19 + 20 + def insert(user_id:, ip_address:) 21 + database.exec 'INSERT INTO validations (user_id, ip_address) VALUES ($1, $2)', [user_id, ip_address] 22 + end 23 + end
+4
lib/database.rb
··· 13 13 14 14 def exec(...) = @pg_conn.exec(...) 15 15 16 + def healthcheck 17 + exec('SELECT NOW() AS time')[0]['time'] 18 + end 19 + 16 20 private 17 21 18 22 def connect!
-9
lib/databaseable.rb
··· 1 - module Databaseable 2 - def initialize(database) 3 - @database = database 4 - end 5 - 6 - protected 7 - 8 - attr_reader :database 9 - end
+14
lib/services.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'services/user_service' 4 + require_relative 'services/validation_service' 5 + 6 + # General class for all service classes 7 + class Services 8 + attr_reader :user, :validation 9 + 10 + def initialize(data_access) 11 + @user = UserService.new data_access 12 + @validation = ValidationService.new data_access 13 + end 14 + end
+18
lib/services/base_service.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require 'sinatra' 4 + 5 + # Base services, initializes data access, services 6 + class BaseService 7 + def initialize(data_access) 8 + @data_access = data_access 9 + end 10 + 11 + protected 12 + 13 + attr_reader :data_access 14 + 15 + def error!(message) 16 + halt 500, message 17 + end 18 + end
+11
lib/services/plant_service.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'base_service' 4 + 5 + # Dealing with plant actions 6 + class PlantService < BaseService 7 + def new_plant(user_id:, name:, plant_type: nil) 8 + plant_type_id = data_access.plant_types.find_or_create(name: plant_type) if plant_type 9 + data_access.plants.insert user_id:, name:, plant_type_id: 10 + end 11 + end
+33
lib/services/user_service.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'base_service' 4 + 5 + # Dealing with user's actions 6 + class UserService < BaseService 7 + # Signs a user up by email, returns a message to be sent back to the user 8 + def sign_up!(email) 9 + raise 'Invalid email' unless /^[^@]+@[^.]+\.[^.]+$/.match? email 10 + 11 + data_access.users.insert email: 12 + rescue PG::UniqueViolation 13 + error! "An account with the email #{email} already exists" 14 + end 15 + 16 + # Validates a code to allow a user to sign in (or to check if a user can sign in) return true or false 17 + def validate_code?(code:, email:, ip:) 18 + user_id = find_id_from_email email 19 + count_within_quantum = data_access.validations.validations_within_time_quantum_for_user_id(user_id)[0]['count'].to_i 20 + raise 'Too many requests' if count_within_quantum >= 10 21 + 22 + data_access.validations.insert(user_id:, ip:) 23 + 24 + results = @data_access.users.find(choose: %i[access_code], id: user_id) 25 + results[0]['access_code'] == code 26 + end 27 + 28 + private 29 + 30 + def find_id_from_email(email) 31 + data_access.users.find(choose: %i[id], email:)[0]['id'] 32 + end 33 + end
+7
lib/services/validation_service.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative 'base_service' 4 + 5 + # Dealing with validation actions 6 + class ValidationService < BaseService 7 + end
+1
views/plants.erb
··· 1 + <h1>Plants</h1>