···11+SELECT 'CREATE DATABASE plant_reminder_development'
22+WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'plant_reminder_development')\gexec
+12
db/create_plants.sql
···11+CREATE TABLE IF NOT EXISTS plant_types (
22+ id SERIAL PRIMARY KEY NOT NULL,
33+ name VARCHAR(100) NOT NULL,
44+ watering_frequency_in_minutes INT
55+);
66+77+CREATE TABLE IF NOT EXISTS plants (
88+ id SERIAL PRIMARY KEY NOT NULL,
99+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
1010+ name VARCHAR(200),
1111+ plant_type_id INT REFERENCES plant_types(id) ON DELETE SET NULL
1212+);
+5
db/create_users.sql
···11+CREATE TABLE IF NOT EXISTS users (
22+ id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
33+ email VARCHAR(100) UNIQUE NOT NULL,
44+ access_code VARCHAR(20) UNIQUE
55+);
+7
db/create_validations.sql
···11+CREATE TABLE IF NOT EXISTS validations (
22+ id SERIAL PRIMARY KEY NOT NULL,
33+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44+ ip_address CIDR,
55+ validated_at TIMESTAMP NOT NULL DEFAULT NOW(),
66+ validated BOOLEAN
77+);
+3-2
lib/data_access.rb
···11# frozen_string_literal: true
2233require_relative 'data_access/users_data_access'
44+require_relative 'data_access/validations_data_access'
4556# General class for all data_access classes
67class DataAccess
77- attr_reader :database, :users
88+ attr_reader :users, :validations
89910 def initialize(database)
1010- @database = database
1111 @users = UsersDataAccess.new database
1212+ @validations = ValidationsDataAccess.new database
1213 end
1314end
+70
lib/data_access/base_data_access.rb
···11+# frozen_string_literal: false
22+33+# Base data access layer
44+class BaseDataAccess
55+ class << self
66+ attr_accessor :table_name
77+ end
88+99+ def initialize(database)
1010+ @database = database
1111+ end
1212+1313+ COLUMNS = [].freeze
1414+1515+ def find_or_create(*, **params)
1616+ base_find(*, **params) do |result, params|
1717+ next result if result.one?
1818+1919+ id = insert(**params)
2020+2121+ params[:id] = id
2222+2323+ find(*, **params)
2424+ end
2525+ end
2626+2727+ # Do not pass block
2828+ def find(*, **params) = base_find(*, **params)
2929+3030+ def insert(**params)
3131+ valid_params = params.select { |column| self.class::COLUMNS.include?(column) }
3232+ insert_statement = build_insert_statement(valid_params.keys)
3333+ database.exec(insert_statement, valid_params.values)[0]['id']
3434+ end
3535+3636+ attr_reader :database
3737+3838+ private
3939+4040+ def base_find(choose: [:id], **params)
4141+ valid_choose = choose & self.class::COLUMNS # the intersection operator is great for things like this!
4242+ query_by = filter_params_by_columns params
4343+ query = build_find_query valid_choose, query_by
4444+ result = database.exec query, [*query_by.values]
4545+ return result unless block_given?
4646+4747+ yield result, params
4848+ end
4949+5050+ def build_insert_statement(columns)
5151+ "INSERT INTO #{self.class.table_name} (".tap do |sql|
5252+ sql << columns.join(', ')
5353+ sql << ') VALUES ('
5454+ sql << (1..columns.length).map { |pos| "$#{pos}" }.join(', ')
5555+ sql << ') RETURNING id'
5656+ end
5757+ end
5858+5959+ def build_find_query(valid_choose, query_by)
6060+ 'SELECT '.tap do |sql|
6161+ sql << valid_choose.join(', ')
6262+ sql << " FROM #{self.class.table_name} WHERE "
6363+ sql << query_by.map.with_index(1) { |(key), index| "#{key}=$#{index}" }.join(' AND ')
6464+ end
6565+ end
6666+6767+ def filter_params_by_columns(params)
6868+ params.select { |column| self.class::COLUMNS.include?(column) }
6969+ end
7070+end
+15
lib/data_access/plant_data_access.rb
···11+# frozen_string_literal: true
22+33+require_relative 'base_data_access'
44+55+# Handles the data access for plants
66+class PlantDataAccess < BaseDataAccess
77+ self.table_name = 'plants'
88+99+ COLUMNS = %i[id user_id name plant_type_id].freeze
1010+1111+ def insert(user_id:, name:, plant_type_id:)
1212+ database.exec 'INSERT INTO plants (user_id, name, plant_type_id) VALUES ($1, $2, $3)',
1313+ [user_id, name, plant_type_id]
1414+ end
1515+end
+10
lib/data_access/plant_type_data_access.rb
···11+# frozen_string_literal: true
22+33+require_relative 'base_data_access'
44+55+# Handles the data access for plant types
66+class PlantTypeDataAccess < BaseDataAccess
77+ self.table_name = 'plant_types'
88+99+ COLUMNS = %i[id name watering_frequency_in_minutes].freeze
1010+end
+5-24
lib/data_access/users_data_access.rb
···11# frozen_string_literal: true
2233-require_relative '../databaseable'
33+require_relative 'base_data_access'
4455# Handles the data access for users
66-class UsersDataAccess
77- include Databaseable
66+class UsersDataAccess < BaseDataAccess
77+ self.table_name = 'users'
8899- COUNT_WITHIN_QUANTUM_SQL = <<~SQL
1010- SELECT COUNT(*) from validations
1111- WHERE user_id = $1 AND validated_at >= NOW() - INTERVAL '30 minutes' AND validated = false
1212- SQL
99+ COLUMNS = %i[id email access_code].freeze
13101414- def sign_up!(email)
1515- raise 'Invalid email' unless /^[^@]+@[^.]+\.[^.]+$/.match? email
1616-1111+ def insert(email:)
1712 database.exec 'INSERT INTO users (email) VALUES ($1)', [email]
1818- 'success'
1919- rescue PG::UniqueViolation
2020- "An account with the email #{email} already exists"
2121- end
2222-2323- def validate_code?(code:, email:, ip:)
2424- user_id = database.exec('SELECT id FROM users WHERE email = $1', [email])[0]['id']
2525- count_within_quantum = database.exec(COUNT_WITHIN_QUANTUM_SQL, [user_id])
2626- raise 'Too many requests' if count_within_quantum[0]['count'].to_i >= 10
2727-2828- database.exec 'INSERT INTO validations (user_id, ip_address) VALUES ($1, $2)', [user_id, ip]
2929-3030- results = database.exec 'SELECT access_code FROM users WHERE id = $1', [user_id]
3131- results[0]['code'] == code
3213 end
3314end
+23
lib/data_access/validations_data_access.rb
···11+# frozen_string_literal: true
22+33+require_relative 'base_data_access'
44+55+# Handles the data access for validations
66+class ValidationsDataAccess < BaseDataAccess
77+ self.table_name = 'validations'
88+99+ COLUMNS = %i[id user_id ip_address validated_at validated].freeze
1010+1111+ COUNT_WITHIN_QUANTUM_SQL = <<~SQL
1212+ SELECT COUNT(*) from validations
1313+ WHERE user_id = $1 AND validated_at >= NOW() - INTERVAL '30 minutes' AND validated = false
1414+ SQL
1515+1616+ def validations_within_time_quantum_for_user_id(user_id)
1717+ database.exec COUNT_WITHIN_QUANTUM_SQL, [user_id]
1818+ end
1919+2020+ def insert(user_id:, ip_address:)
2121+ database.exec 'INSERT INTO validations (user_id, ip_address) VALUES ($1, $2)', [user_id, ip_address]
2222+ end
2323+end
+4
lib/database.rb
···13131414 def exec(...) = @pg_conn.exec(...)
15151616+ def healthcheck
1717+ exec('SELECT NOW() AS time')[0]['time']
1818+ end
1919+1620 private
17211822 def connect!
···11+# frozen_string_literal: true
22+33+require_relative 'base_service'
44+55+# Dealing with user's actions
66+class UserService < BaseService
77+ # Signs a user up by email, returns a message to be sent back to the user
88+ def sign_up!(email)
99+ raise 'Invalid email' unless /^[^@]+@[^.]+\.[^.]+$/.match? email
1010+1111+ data_access.users.insert email:
1212+ rescue PG::UniqueViolation
1313+ error! "An account with the email #{email} already exists"
1414+ end
1515+1616+ # Validates a code to allow a user to sign in (or to check if a user can sign in) return true or false
1717+ def validate_code?(code:, email:, ip:)
1818+ user_id = find_id_from_email email
1919+ count_within_quantum = data_access.validations.validations_within_time_quantum_for_user_id(user_id)[0]['count'].to_i
2020+ raise 'Too many requests' if count_within_quantum >= 10
2121+2222+ data_access.validations.insert(user_id:, ip:)
2323+2424+ results = @data_access.users.find(choose: %i[access_code], id: user_id)
2525+ results[0]['access_code'] == code
2626+ end
2727+2828+ private
2929+3030+ def find_id_from_email(email)
3131+ data_access.users.find(choose: %i[id], email:)[0]['id']
3232+ end
3333+end