diff --git a/Gemfile b/Gemfile index b1a320395a..1199b5f236 100644 --- a/Gemfile +++ b/Gemfile @@ -11,3 +11,12 @@ end group :development, :test do gem 'rubocop', '1.20' end + +gem "sinatra", "~> 3.0" +gem "sinatra-contrib", "~> 3.0" +gem "webrick", "~> 1.8" +gem "rack-test", "~> 2.1" + +gem "pg", "~> 1.5" + +gem "bcrypt", "~> 3.1" diff --git a/Gemfile.lock b/Gemfile.lock index 66064703c7..7dad26ff98 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,11 +3,21 @@ GEM specs: ansi (1.5.0) ast (2.4.2) + bcrypt (3.1.18) diff-lcs (1.4.4) docile (1.4.0) + multi_json (1.15.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) parallel (1.20.1) parser (3.0.2.0) ast (~> 2.4.1) + pg (1.5.3) + rack (2.2.7) + rack-protection (3.0.6) + rack + rack-test (2.1.0) + rack (>= 1.3) rainbow (3.0.0) regexp_parser (2.1.1) rexml (3.2.5) @@ -36,6 +46,7 @@ GEM rubocop-ast (1.11.0) parser (>= 3.0.1.1) ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) simplecov (0.21.2) docile (~> 1.1) simplecov-html (~> 0.11) @@ -46,18 +57,38 @@ GEM terminal-table simplecov-html (0.12.3) simplecov_json_formatter (0.1.3) + sinatra (3.0.6) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.0.6) + tilt (~> 2.0) + sinatra-contrib (3.0.6) + multi_json + mustermann (~> 3.0) + rack-protection (= 3.0.6) + sinatra (= 3.0.6) + tilt (~> 2.0) terminal-table (3.0.1) unicode-display_width (>= 1.1.1, < 3) + tilt (2.1.0) unicode-display_width (2.0.0) + webrick (1.8.1) PLATFORMS ruby + x86_64-linux DEPENDENCIES + bcrypt (~> 3.1) + pg (~> 1.5) + rack-test (~> 2.1) rspec rubocop (= 1.20) simplecov simplecov-console + sinatra (~> 3.0) + sinatra-contrib (~> 3.0) + webrick (~> 1.8) RUBY VERSION ruby 3.0.2p107 diff --git a/README.md b/README.md index 465eda879b..0af516e9a7 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,68 @@ -Chitter Challenge -================= +# Chitter Challenge -* Feel free to use Google, your notes, books, etc. but work on your own -* If you refer to the solution of another coach or student, please put a link to that in your README -* If you have a partial solution, **still check in a partial solution** -* You must submit a pull request to this repo with your code by 10am Monday morning +The solo challenge for Week 4 was to write a small Twitter clone that will allow the users to post messages to a public stream. -Challenge: -------- +You can visit my site here: [https://chitter-app-4z4y.onrender.com/](https://chitter-app-4z4y.onrender.com/) -As usual please start by forking this repo. +You should be able to sign in with the following credentials: +- Username: example@example.com +- Password: 123 -We are going to write a small Twitter clone that will allow the users to post messages to a public stream. +I have not yet added the functionality to email a user when they are tagged. But here are some screenshots of the working app. You can sign up, log in, logout, post peeps, tag users in peeps and reply to peeps. -Features: -------- +![chitter feed page](designs/chitter-feed.png) +![chitter sign up](designs/chitter-signup.png) +![chitter log in](designs/chitter-login.png) +![chitter replies](designs/chitter-reply.png) +![test coverage](designs/test_result.png) +The diagrams below are from my initial plan. Whilst I was working through the challenge the final implementation is changed slightly from this. + +My #all_with_user method is doing a lot of work - because I want my list of peeps to display the peep, the user, the number of replies and the tags. This involves getting all of the peeps from the database - and doing some manipulation to create a reply_count attribute and returning only peeps that are not a reply. + +In creating a Peep object, I am also making another call to the database to get the list of tags. I chose to add the tags to a separate table because there is a many-to-many relationship - one peep can have multiple tags and one user can be tagged multiple times. I'm not sure if this is the most effective way of implementing this? + +I was also a little unsure on where the error handling should go - in the model or the controller? It seemed easier to return an alternative status direct from the model. + +The focus of this challenge was not CSS, I have added some CSS to make it look nice, but it is not fully responsive. + +Going forward, I would like to try using an Object Relational Mapper as the database interface instead of Repository classes. + +## Instructions + +```bash +# To use my program locally - go to http://localhost:9292/ + +$ git clone https://github.com/sarahc-dev/chitter-challenge.git +$ cd chitter_challenge +$ bundle install +$ createdb chitter_test +$ psql -h 127.0.0.1 chitter_test < chitter_tables.sql +$ psql -h 127.0.0.1 chitter_test < spec/seeds_chitter_test.sql +$ rspec +$ rackup ``` -STRAIGHT UP +## Technologies Used + +- Miro - Designing and planning +- Diagram.codes - Sequence diagrams +- Ruby +- Sinatra & ERB +- BCrypt +- PostgreSQL +- HTML & CSS +- Render (cloud hosting) + +## User Stories + +```plain As a Maker -So that I can let people know what I am doing +So that I can let people know what I am doing I want to post a message (peep) to chitter -As a maker -So that I can see what others are saying +As a Maker +So that I can see what others are saying I want to see all peeps in reverse chronological order As a Maker @@ -35,8 +73,6 @@ As a Maker So that I can post messages on Chitter as me I want to sign up for Chitter -HARDER - As a Maker So that only I can post messages on Chitter as me I want to log in to Chitter @@ -45,79 +81,240 @@ As a Maker So that I can avoid others posting messages on Chitter as me I want to log out of Chitter -ADVANCED - As a Maker So that I can stay constantly tapped in to the shouty box of Chitter I want to receive an email if I am tagged in a Peep + +As a Maker +So that I can start a conversation +I want to reply to a peep from another Maker ``` -Technical Approach: ------ +### Functionality + +- See all peeps without being logged in, in reverse chronological order +- Peeps have the name and username of the Maker and the time of the peep +- Sign up - with email (unique), password, name and username (unique) +- Log in - email and password +- Log out +- Post peep +- Reply to a peep +- Tag other Makers +- Receive an email if tagged -In the last two weeks, you integrated a database using the `pg` gem and Repository classes. You also implemented small web applications using Sinatra, RSpec, HTML and ERB views to make dynamic webpages. You can continue to use this approach when building Chitter Challenge. +## Modelling and Planning Web Application -You can refer to the [guidance on Modelling and Planning a web application](https://github.com/makersacademy/web-applications/blob/main/pills/modelling_and_planning_web_application.md), to help you in planning the different web pages you will need to implement this challenge. If you'd like to deploy your app to Heroku so other people can use it, [you can follow this guidance](https://github.com/makersacademy/web-applications/blob/main/html_challenges/07_deploying.md). +Initially, I have created some sequence diagrams for the main functionality of my program - posting a peep, logging in and signing up to Chitter. -If you'd like more technical challenge now, try using an [Object Relational Mapper](https://en.wikipedia.org/wiki/Object-relational_mapping) as the database interface, instead of implementing your own Repository classes. +### Sequence diagrams -Some useful resources: -**Ruby Object Mapper** -- [ROM](https://rom-rb.org/) +Post a new peep: -**ActiveRecord** -- [ActiveRecord ORM](https://guides.rubyonrails.org/active_record_basics.html) -- [Sinatra & ActiveRecord setup](https://learn.co/lessons/sinatra-activerecord-setup) +![post new peep](designs/post-a-peep.png) -Notes on functionality: ------- +New user sign up: -* You don't have to be logged in to see the peeps. -* Makers sign up to chitter with their email, password, name and a username (e.g. samm@makersacademy.com, password123, Sam Morgan, sjmog). -* The username and email are unique. -* Peeps (posts to chitter) have the name of the maker and their user handle. -* Your README should indicate the technologies used, and give instructions on how to install and run the tests. +![signup user](designs/signup-user.png) -Bonus: ------ +Log in the user: -If you have time you can implement the following: +![login user](designs/login-user.png) -* In order to start a conversation as a maker I want to reply to a peep from another maker. +### Design database -And/Or: +Extracted the nouns from the user stories to infer the table names and properties. I have also considered the relationships between the tables. -* Work on the CSS to make it look good. +- One peep can have one user (as author) +- One user can have many peeps +- Replies: One peep can also have one parent (a peep) or null +- Tags: One peep can tag many users, one user can have many tags -Good luck and let the chitter begin! +To implement the tags, as this is a many-to-many relationship, I will need to create a join table - which contains user_id and peep_id. -Code Review ------------ +#### Infer the table names and columns -In code review we'll be hoping to see: +| Record | Properties | +| ------ | ---------------------------------------------------------------------- | +| peep | message, timestamp, author (user_id), reply (parent_id), tag (user_id) | +| user | email (unique), password (encrypted), name, username (unique) | -* All tests passing -* High [Test coverage](https://github.com/makersacademy/course/blob/main/pills/test_coverage.md) (>95% is good) -* The code is elegant: every class has a clear responsibility, methods are short etc. +1. Name of the first table (always plural): `peeps` -Reviewers will potentially be using this [code review rubric](docs/review.md). Referring to this rubric in advance may make the challenge somewhat easier. You should be the judge of how much challenge you want at this moment. + Column names: `message`, `timestamp`, `user_id`, `parent_id` -Notes on test coverage ----------------------- +2. Name of the second table (always plural): `users` -Please ensure you have the following **AT THE TOP** of your spec_helper.rb in order to have test coverage stats generated -on your pull request: + Column names: `email`, `password`, `name`, `username` + +3. Name of the join table (table1_table2): `peeps_users` + + Column names: `peep_id`, `user_id` + +#### Decide the column types + +```plain +Table: peeps +id: SERIAL +message: text +timestamp: timestamp +user_id: user_id +peep_id: peep_id + +Table: users +id: SERIAL +email: text +password: text +name: text +username: text +``` + +#### Write the SQL + +```sql +CREATE TABLE peeps ( + id SERIAL PRIMARY KEY, + message text, + timestamp timestamp, + user_id int, + peep_id int, + constraint fk_user foreign key(user_id) references users(id), + constraint fk_peep foreign key(peep_id) references peeps(id) +); + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email text, + password text, + name text, + username text +); + +CREATE TABLE peeps_users ( + peep_id int, + user_id int, + constraint fk_peep foreign key(peep_id) references peeps(id), + constraint fk_user foreign key(user_id) references users(id), + PRIMARY KEY (peep_id, user_id) +); +``` + +### Planning pages + +I have created a diagram outlining the different pages the user will use and how they will navigate to these pages. + +![pages design](designs/designing-pages.jpg) + +### Planning routes + +I have created RESTful routes for the peeps but was unsure regarding the login and signup pages. + +| Route Name | URL Path | HTTP Method | Purpose | +| -------------------------------- | ---------- | ----------- | ---------------------------- | +| Index | / | GET | Show signup page | +| All | /peeps | GET | Display all peeps | +| New | /peeps/new | GET | Show form for new peep | +| Create | /peeps | POST | Creates new peep | +| Show | /peeps/:id | GET | Shows one peep (for replies) | +| Edit, Update, Delete - not using | | +| Login | /login | GET | Display login page | +| Login user | /login | POST | Login user and redirect | +| Sign up user | /signup | POST | Sign up user and redirect | + +### Designing the Repository classes ```ruby -require 'simplecov' -require 'simplecov-console' - -SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::Console, - # Want a nice code coverage website? Uncomment this next line! - # SimpleCov::Formatter::HTMLFormatter -]) -SimpleCov.start +class Peep + attr_accessor :id, :message, :timestamp, :parent_id, :user +end + +class PeepRepository + def all_with_user + # displays all peeps with user info + + # No arguments + # Returns array of Peep and user (author) + + SQL = ' + SELECT peeps.id, message, timestamp, users.id as user_id, users.name, users.username + FROM peeps + JOIN users ON users.id = peeps.user_id + WHERE peep_id IS NULL;' + end + + def create(peep) + # creates a new peep + # takes a peep object as an argument + # Returns nothing - creates a new peep + + SQL = ' + INSERT INTO peeps (message, timestamp, user_id, peep_id) + VALUES($1, CURRENT_TIMESTAMP, $2, $3);' + end + + def find_by_id(id) + # Finds a single peep (for replies) + # Takes an id as an argument + # Returns a single peep + + SQL = ' + SELECT peeps.id, message, timestamp, users.id as user_id, users.name, users.username + FROM peeps + JOIN users ON users.id = peeps.user_id + WHERE peep_id IS NULL and peeps.id = $1;' + end + + def get_replies(id) + # gets a list of replies to a single peep + # takes the peep id as an argument + # returns a list of peep objects + + sql = 'SELECT peeps.id, message, timestamp, users.id as user_id, name, username + FROM peeps JOIN users ON users.id = peeps.user_id + WHERE peep_id = $1;' + end + + def get_tags(id) + # finds the tags associated with a single peep + # takes the peep id as an argument + # returns a list of usernames tagged + SQL = 'SELECT users.id, users.username + FROM users + JOIN peeps_users ON peeps_users.user_id = users.id + JOIN peeps ON peeps_users.peep_id = peeps.id + WHERE peeps.id = 4;' + end +end + +class User + attr_accessor :id, :email, :password, :name, :username +end + +class UserRepository + + def create(user) + # creates a new user + # takes a user object argument + # encrypt password + # returns nothing - creates new user + + sql = ' + INSERT INTO users (email, password, name, username) + VALUES($1, $2, $3, $4);' + end + + def find_by_email(email) + # Takes an email as an argument (and password?) + # Returns a single user + + sql = ' + SELECT id, email, name, username + FROM users + WHERE email = $1;' + # (and password = $2)? + end +end ``` -You can see your test coverage when you run your tests. If you want this in a graphical form, uncomment the `HTMLFormatter` line and see what happens! +### Test-drive and implement + +I am going to write my tests directly into the test file. diff --git a/app.rb b/app.rb new file mode 100644 index 0000000000..ac48bddb2a --- /dev/null +++ b/app.rb @@ -0,0 +1,111 @@ +require 'sinatra/base' +require 'sinatra/reloader' +require_relative 'lib/database_connection' +require_relative 'lib/peep_repository' +require_relative 'lib/user_repository' + +DatabaseConnection.connect + +class Application < Sinatra::Base + + enable :sessions + + configure :development do + register Sinatra::Reloader + end + + get '/' do + return redirect('/peeps') unless session[:user_id].nil? + + return erb(:index) + end + + get '/peeps' do + repo = PeepRepository.new + @peeps = repo.all_with_user + + return erb(:peeps) + end + + get '/peeps/new' do + return redirect('/login') if session[:user_id].nil? + + return erb(:create_peep) + end + + post '/peeps' do + halt 400, "peep should not be empty" if params[:message].empty? + + # prevents dangerous input + clean_param = Rack::Utils.escape_html(params[:message]) + + repo = PeepRepository.new + peep = Peep.new + peep.message = clean_param + peep.user_id = session[:user_id] + peep.peep_id = params[:peep_id] + id = repo.create(peep) + # p id[0]['id'] + + UserRepository.new.tag_users(id[0]['id'], + params[:tags]) unless params[:tags].nil? || params[:tags].empty? + + return params[:peep_id] ? redirect("/peeps/#{params[:peep_id]}") : redirect('/peeps') + end + + get '/peeps/:id' do + return redirect('/login') if session[:user_id].nil? + + id = params[:id] + repo = PeepRepository.new + @peep = repo.find_by_id(id) + @replies = repo.get_replies(id) + + return erb(:peep) + end + + post '/signup' do + halt 400, "fields must be completed" if params[:email].empty? || \ + params[:password].empty? || params[:name].empty? || params[:username].empty? + + repo = UserRepository.new + user = User.new + user.email = Rack::Utils.escape_html(params[:email]) + user.password = Rack::Utils.escape_html(params[:password]) + user.name = Rack::Utils.escape_html(params[:name]) + user.username = Rack::Utils.escape_html(params[:username]) + + repo.create(user) + return erb(:signup_success) + + rescue PG::UniqueViolation + status 400 + return erb(:signup_error) + end + + get '/login' do + return erb(:login) + end + + post '/login' do + halt 400, "Bad request" unless params[:email].count("'").zero? + + repo = UserRepository.new + # log_in method returns user id + user_id = repo.log_in(params[:email], params[:password]) + + if user_id.nil? + status 403 + return erb(:login_error) + else + session[:user_id] = user_id + return erb(:login_success) + end + end + + get '/logout' do + session[:user_id] = nil + + return redirect('/peeps') + end +end diff --git a/chitter_tables.sql b/chitter_tables.sql new file mode 100644 index 0000000000..622d472a05 --- /dev/null +++ b/chitter_tables.sql @@ -0,0 +1,29 @@ +DROP TABLE IF EXISTS "peeps_users"; +DROP TABLE IF EXISTS "peeps"; +DROP TABLE IF EXISTS "users"; + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email text NOT NULL UNIQUE, + password text NOT NULL, + name text, + username text NOT NULL UNIQUE +); + +CREATE TABLE peeps ( + id SERIAL PRIMARY KEY, + message text NOT NULL, + timestamp timestamp, + user_id int NOT NULL, + peep_id int, + constraint fk_user foreign key(user_id) references users(id), + constraint fk_peep foreign key(peep_id) references peeps(id) +); + +CREATE TABLE peeps_users ( + peep_id int, + user_id int, + constraint fk_peep foreign key(peep_id) references peeps(id), + constraint fk_user foreign key(user_id) references users(id), + PRIMARY KEY (peep_id, user_id) +); \ No newline at end of file diff --git a/config.ru b/config.ru new file mode 100644 index 0000000000..af14ef717e --- /dev/null +++ b/config.ru @@ -0,0 +1,2 @@ +require './app' +run Application diff --git a/designs/chitter-feed.png b/designs/chitter-feed.png new file mode 100644 index 0000000000..49f2c825c0 Binary files /dev/null and b/designs/chitter-feed.png differ diff --git a/designs/chitter-login.png b/designs/chitter-login.png new file mode 100644 index 0000000000..129b7b3606 Binary files /dev/null and b/designs/chitter-login.png differ diff --git a/designs/chitter-reply.png b/designs/chitter-reply.png new file mode 100644 index 0000000000..6b70293ac2 Binary files /dev/null and b/designs/chitter-reply.png differ diff --git a/designs/chitter-signup.png b/designs/chitter-signup.png new file mode 100644 index 0000000000..6d4703ed95 Binary files /dev/null and b/designs/chitter-signup.png differ diff --git a/designs/designing-pages.jpg b/designs/designing-pages.jpg new file mode 100644 index 0000000000..f5e3fdcd36 Binary files /dev/null and b/designs/designing-pages.jpg differ diff --git a/designs/login-user.png b/designs/login-user.png new file mode 100644 index 0000000000..988aa6f908 Binary files /dev/null and b/designs/login-user.png differ diff --git a/designs/post-a-peep.png b/designs/post-a-peep.png new file mode 100644 index 0000000000..c90c46792b Binary files /dev/null and b/designs/post-a-peep.png differ diff --git a/designs/signup-user.png b/designs/signup-user.png new file mode 100644 index 0000000000..0096f219c0 Binary files /dev/null and b/designs/signup-user.png differ diff --git a/designs/test_result.png b/designs/test_result.png new file mode 100644 index 0000000000..a9513115ac Binary files /dev/null and b/designs/test_result.png differ diff --git a/lib/database_connection.rb b/lib/database_connection.rb new file mode 100644 index 0000000000..dfaa146958 --- /dev/null +++ b/lib/database_connection.rb @@ -0,0 +1,26 @@ +require 'pg' + +class DatabaseConnection + def self.connect + if ENV['DATABASE_URL'] != nil + @connection = PG.connect(ENV['DATABASE_URL']) + return + end + + if ENV['ENV'] == 'test' + database_name = 'music_library_test' + else + database_name = 'music_library' + end + @connection = PG.connect({ host: '127.0.0.1', dbname: database_name }) + end + + def self.exec_params(query, params) + if @connection.nil? + raise 'DatabaseConnection.exec_params: Cannot run a SQL query as the connection to'\ + 'the database was never opened. Did you make sure to call first the method '\ + '`DatabaseConnection.connect` in your app.rb file (or in your tests spec_helper.rb)?' + end + @connection.exec_params(query, params) + end +end diff --git a/lib/peep.rb b/lib/peep.rb new file mode 100644 index 0000000000..5f90ee7ebc --- /dev/null +++ b/lib/peep.rb @@ -0,0 +1,7 @@ +class Peep + attr_accessor :id, :message, :timestamp, :peep_id, :user_id, :user, :reply_count, :tags + + def initialize + @tags = [] + end +end diff --git a/lib/peep_repository.rb b/lib/peep_repository.rb new file mode 100644 index 0000000000..19d6c46c2e --- /dev/null +++ b/lib/peep_repository.rb @@ -0,0 +1,105 @@ +require_relative 'peep' +require_relative 'user' + +class PeepRepository + def all_with_user + peeps = [] + + sql = 'SELECT peeps.id, message, timestamp, peeps.user_id, peep_id as parent_id, + name, username, email FROM peeps + JOIN users ON users.id = peeps.user_id ORDER BY "timestamp" DESC;' + + result_set = DatabaseConnection.exec_params(sql, []) + + result_set.each do |record| + # counts replies for each record + record['reply_count'] = result_set.count { |result| result['parent_id'] == record['id'] } + # only add peep if not a reply + peeps << create_peep(record) if record['parent_id'].nil? + end + return peeps + end + + def create(peep) + sql = 'INSERT INTO peeps (message, timestamp, user_id, peep_id) + VALUES($1, CURRENT_TIMESTAMP, $2, $3) RETURNING id;' + + params = [peep.message, peep.user_id, peep.peep_id] + + DatabaseConnection.exec_params(sql, params) + end + + def find_by_id(id) + sql = 'SELECT peeps.id, message, timestamp, user_id, name, username, email + FROM peeps + JOIN users ON users.id = peeps.user_id + WHERE peep_id IS NULL and peeps.id = $1;' + + params = [id] + + result = DatabaseConnection.exec_params(sql, params) + + return create_peep(result[0]) + end + + def get_replies(id) + replies = [] + + sql = 'SELECT peeps.id, message, timestamp, user_id, name, username + FROM peeps JOIN users ON users.id = peeps.user_id + WHERE peep_id = $1;' + + params = [id] + + result_set = DatabaseConnection.exec_params(sql, params) + + result_set.each do |record| + replies << create_peep(record) + end + return replies + end + + private + + def create_peep(record) + user = create_user(record) + peep = Peep.new + peep.id = record['id'].to_i + peep.message = record['message'] + peep.timestamp = format_timestamp(record['timestamp']) + peep.reply_count = record['reply_count'] + peep.user = user + peep = get_tags(peep) + + return peep + end + + def create_user(record) + user = User.new + user.id = record['user_id'] + user.name = record['name'] + user.username = record['username'] + user.email = record['email'] + + return user + end + + def get_tags(peep) + sql = 'SELECT users.id, username, email FROM users + JOIN peeps_users ON peeps_users.user_id = users.id + JOIN peeps ON peeps_users.peep_id = peeps.id WHERE peeps.id = $1;' + + params = [peep.id] + + result_set = DatabaseConnection.exec_params(sql, params) + + result_set.each do |record| + peep.tags << create_user(record) + end + return peep + end + + def format_timestamp(timestamp) + return DateTime.parse(timestamp).strftime '%d %b %Y %H:%M:%S' + end +end diff --git a/lib/user.rb b/lib/user.rb new file mode 100644 index 0000000000..1f12d2cc2e --- /dev/null +++ b/lib/user.rb @@ -0,0 +1,3 @@ +class User + attr_accessor :id, :email, :password, :name, :username +end diff --git a/lib/user_repository.rb b/lib/user_repository.rb new file mode 100644 index 0000000000..1798660e88 --- /dev/null +++ b/lib/user_repository.rb @@ -0,0 +1,78 @@ +require_relative 'user' +require 'bcrypt' + +class UserRepository + def create(user) + encrypted_password = BCrypt::Password.create(user.password) + + sql = ' + INSERT INTO users (email, password, name, username) + VALUES($1, $2, $3, $4);' + + params = [user.email, encrypted_password, user.name, user.username] + + DatabaseConnection.exec_params(sql, params) + end + + def log_in(email, submitted_password) + user = find_by_email(email) + + return nil if user.nil? + + stored_password = BCrypt::Password.new(user.password) + + return user.id if stored_password == submitted_password + end + + def tag_users(peep_id, tags) + tags_as_array = tags.gsub(/[,@]/, "").split + + tags_as_array.each do |tag| + user = find_by_username(tag) + next if user.nil? + + sql = 'INSERT INTO peeps_users (peep_id, user_id) + VALUES ($1, $2);' + + params = [peep_id, user.id] + + DatabaseConnection.exec_params(sql, params) + end + end + + def find_by_username(username) + sql = 'SELECT id, email, name, username + FROM users + WHERE username = $1;' + + params = [username] + + user = DatabaseConnection.exec_params(sql, params) + + return create_user(user[0]) unless user.count.zero? + end + + private + + def find_by_email(email) + sql = 'SELECT id, email, password, name, username + FROM users + WHERE email = $1;' + + params = [email] + + user = DatabaseConnection.exec_params(sql, params) + + return create_user(user[0]) unless user.count.zero? + end + + def create_user(record) + user = User.new + user.id = record['id'].to_i + user.email = record['email'] + user.password = record['password'] + user.name = record['name'] + user.username = record['username'] + return user + end +end diff --git a/public/bird.svg b/public/bird.svg new file mode 100644 index 0000000000..f9fa1cf18a --- /dev/null +++ b/public/bird.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000000..6eb7db0b3d Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000000..e6f82c6e75 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,205 @@ +* { + margin: 0; +} + +body { + background-color: #a8b7b6; + color: #2a3945; + font-family: "Rubik", sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} + +h1 { + font-size: 2.5rem; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: #a8b7b6; +} + +ul { + list-style: none; + padding: 0; +} + +input { + border: none; + font: inherit; +} + +input[type="submit"], +.button { + background-color: #2a3945; + border: none; + color: #fff; + font-weight: 500; + padding: 0.5rem 1rem; +} + +.home, +.create-new, +.login, +.box { + background-color: #dfded9; + border-radius: 20px; + padding: 2.5rem; + margin: 1rem; + text-align: center; + box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px; +} + +h1, +h2 { + margin-bottom: 1rem; +} + +.home h2 { + margin-top: 1rem; +} + +.home input, +.login input { + margin-bottom: 0.75rem; +} + +.margin-bottom { + margin-bottom: 1.5rem; +} + +.peeps, +.peep { + max-width: 650px; +} + +.peeps { + margin: 0 2rem; + text-align: center; +} + +.peeps ul, +.peep .box { + text-align: left; +} + +.peeps li { + background-color: #dfded9; + box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px; + border-radius: 8px; + padding: 1rem; +} + +.peeps li + li { + margin-top: 1rem; +} + +.author { + display: flex; + align-items: baseline; + margin-top: 1rem; +} + +.author > p:first-child { + margin-right: 1rem; +} + +.tags { + color: #ef820d; + display: flex; + margin-bottom: 0.5rem; +} + +.tags p + p { + margin-left: 0.5rem; +} + +.replies { + display: flex; + justify-content: space-between; + margin-top: 1rem; +} + +.create { + display: inline-block; + margin-bottom: 1.5rem; +} + +.create-new input[type="submit"] { + margin: 1rem 0; +} + +.create-new .note { + font-size: 0.75rem; + margin-top: 0.25rem; +} + +.peep-container { + margin-top: 1rem; +} + +.peep-container input { + margin-top: 0.5rem; + width: 100%; +} + +.peep h3, +.back { + margin-left: 1rem; +} + +.back:hover { + color: #dfded9; +} + +.reply, +.reply-form { + margin-left: 4.5rem; +} + +.create-new form, +.reply-form { + text-align: left; +} + +#reply { + width: 100%; + margin: 1rem 0; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 2.5rem; +} + +header a:hover { + color: #dfded9; +} + +header div { + display: flex; + align-items: center; +} + +header div *:first-child { + margin-right: 1rem; +} + +footer { + padding: 1rem; + text-align: center; +} diff --git a/spec/integration/app_spec.rb b/spec/integration/app_spec.rb new file mode 100644 index 0000000000..62674d28e6 --- /dev/null +++ b/spec/integration/app_spec.rb @@ -0,0 +1,324 @@ +require 'spec_helper' +require 'rack/test' +require_relative '../../app' + +describe Application do + include Rack::Test::Methods + + let(:app) { Application.new } + + context 'GET to /' do + it 'returns 200 OK and the signup form' do + response = get('/') + + expect(response.status).to eq 200 + expect(response.body).to include('

Sign up

') + expect(response.body).to include('
') + expect(response.body).to include('') + expect(response.body).to include('') + expect(response.body).to include('') + expect(response.body).to include('') + end + end + + context 'GET to /peeps' do + it 'returns 200 OK with a list of peeps' do + response = get('/peeps') + + expect(response.status).to eq 200 + expect(response.body).to include('Chitter Feed') + expect(response.body).to include('

Hello world

') + end + + it 'shows the number of replies to a peep' do + response = get('/peeps') + + expect(response.body).to include('1 reply') + end + + it 'shows any tags on a peep' do + response = get('/peeps') + + expect(response.body).to include('@bob678') + end + end + + context 'GET to /peeps/new' do + it 'returns 302 Redirect to /login if user not logged in' do + response = get('/peeps/new') + expect(response.status).to eq 302 + end + + it 'returns 200 OK with a form to create new peep if logged in' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + response = get('/peeps/new') + + expect(response.status).to eq 200 + expect(response.body).to include('

Create a new peep

') + expect(response.body).to include('') + expect(response.body).to include('') + end + end + + context 'POST to /peeps' do + it 'returns status 302 and redirects to /peeps showing new peep' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + response = post( + '/peeps', + message: 'Testing 123', + ) + + expect(response.status).to eq 302 + + peeps = get('/peeps') + expect(peeps.body).to include('

Testing 123

') + end + + it 'returns an error if peep is empty' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + response = post( + '/peeps', + message: '' + ) + + expect(response.status).to eq 400 + expect(response.body).to eq 'peep should not be empty' + end + + it 'escapes potentially dangerous html in peep' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + response = post( + '/peeps', + message: '' + ) + + peeps = get('/peeps') + expect(peeps.body).to include('

<script>I am bad</script>

') + end + + it 'replies to a peep' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + response = post( + '/peeps', + message: 'This is a reply', + peep_id: 1 + ) + + expect(response.status).to eq 302 + replies = get('/peeps/1') + expect(replies.body).to include('This is a reply') + end + + it 'adds a tag to a peep' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + response = post( + '/peeps', + tags: 'freddo', + message: 'Testing tags' + ) + + peeps = get('/peeps') + expect(peeps.body).to include('@freddo') + end + + it 'adds multiple tags to a peep' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + response = post( + '/peeps', + tags: 'freddo, bob678', + message: 'Testing tags' + ) + + peeps = get('/peeps/9') + expect(peeps.body).to include('

@freddo

') + expect(peeps.body).to include('

@bob678

') + end + end + + context 'GET to /peeps/:id' do + it 'returns 302 status and redirects if not logged in' do + peep = get('/peeps/2') + + expect(peep.status).to eq 302 + end + + it 'returns 200 OK and an individual peep' do + post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + peep = get('/peeps/2') + + expect(peep.status).to eq 200 + end + end + + context 'POST to /signup' do + it 'adds a new user' do + response = post( + '/signup', + username: 'jimmy', + name: 'Jim', + email: 'jim@example.com', + password: '123' + ) + + expect(response.status).to eq 200 + expect(response.body).to include('You have successfully signed up for Chitter!') + end + + it 'returns an error if any fields are empty' do + response = post( + '/signup', + username: '', + name: 'Jim', + email: 'jim@example.com', + password: '123' + ) + expect(response.status).to eq 400 + expect(response.body).to eq 'fields must be completed' + end + + it 'escapes dangerous inputs' do + response = post( + '/signup', + username: '', + name: 'Jimy', + email: 'jimy@example.com', + password: '123' + ) + + expect(response.status).to eq 200 + expect(response.body).to include('You have successfully signed up for Chitter!') + + post( + '/login', + email: 'jimy@example.com', + password: '123' + ) + + response = post( + '/peeps', + message: 'Message from bad user' + ) + + peeps = get('/peeps') + expect(peeps.body).to include('

<script>I am bad</script>

') + end + + it 'returns error page if username not unique' do + response = post( + '/signup', + username: 'freddo', + name: 'Jimy', + email: 'jimy@example.com', + password: '123' + ) + + expect(response.status).to eq 400 + expect(response.body).to include('The username and email must be unique') + end + + it 'returns error page if email not unique' do + response = post( + '/signup', + username: 'fred', + name: 'Jimy', + email: 'fred@example.com', + password: '123' + ) + + expect(response.status).to eq 400 + expect(response.body).to include('The username and email must be unique') + end + end + + context 'GET to /login' do + it 'returns 200 OK and the login form' do + response = get('/login') + + expect(response.status).to eq 200 + expect(response.body).to include('

Log in to Chitter

') + expect(response.body).to include('') + expect(response.body).to include('') + end + end + + context 'POST to /login' do + it 'returns 200 OK and logs the user in' do + response = post( + '/login', + email: 'fred@example.com', + password: '123' + ) + + expect(response.status).to eq 200 + end + + it 'returns 403 Unauthorized if email and password do not match' do + response = post( + '/login', + email: "fred@example.com", + password: '1234' + ) + + expect(response.status).to eq 403 + expect(response.body).to include('The username and password do not match') + end + + it 'does not allow dangerous inputs and returns status 400 Bad Request' do + response = post( + '/login', + email: "fred@example.com'--", + password: '123' + ) + + expect(response.status).to eq 400 + expect(response.body).to eq "Bad request" + end + end + + context 'GET to /logout' do + it 'returns 302 Found redirect' do + response = get('/logout') + + expect(response.status).to eq 302 + end + end +end diff --git a/spec/peep_repository_spec.rb b/spec/peep_repository_spec.rb new file mode 100644 index 0000000000..bc57f96faf --- /dev/null +++ b/spec/peep_repository_spec.rb @@ -0,0 +1,104 @@ +require 'peep_repository' + +def reset_peeps_table + seed_sql = File.read('spec/seeds_chitter_test.sql') + connection = PG.connect({ host: '127.0.0.1', dbname: 'chitter_test' }) + connection.exec(seed_sql) +end + +describe PeepRepository do + before(:each) do + reset_peeps_table + end + + context '#all_with_user' do + it 'finds all peeps that are not replies and their author' do + repo = PeepRepository.new + + peeps = repo.all_with_user + expect(peeps.length).to eq 3 + expect(peeps.first.id).to eq 2 + expect(peeps.first.message).to eq 'This is a great peep' + expect(peeps.first.timestamp).to eq '03 May 2023 16:23:34' + expect(peeps.first.user.name).to eq 'Fred' + expect(peeps.first.user.username).to eq 'freddo' + end + + it 'returns the correct reply count for each peep' do + repo = PeepRepository.new + + peeps = repo.all_with_user + expect(peeps.first.reply_count).to eq 1 + expect(peeps.last.reply_count).to eq 0 + end + + it 'returns a list of tags for each peep' do + repo = PeepRepository.new + + peeps = repo.all_with_user + expect(peeps.first.tags).to eq [] + expect(peeps[1].tags.length).to eq 1 + expect(peeps[1].tags.first.username).to eq 'bob678' + end + + it 'orders the result set in reverse chronological order' do + repo = PeepRepository.new + + peeps = repo.all_with_user + expect(peeps.last.id).to eq 1 + expect(peeps.last.message).to eq 'Hello world' + expect(peeps.last.timestamp).to eq '28 Apr 2023 12:45:05' + end + end + + context '#create' do + it 'creates a new peep' do + repo = PeepRepository.new + peep = Peep.new + peep.message = 'This is a new peep' + peep.user_id = 1 + + repo.create(peep) + expect(repo.all_with_user.length).to eq 4 + expect(repo.all_with_user.first.message).to eq 'This is a new peep' + expect(repo.all_with_user.first.user.name).to eq 'Bob' + end + + it 'creates a reply' do + repo = PeepRepository.new + peep = Peep.new + peep.message = 'This is a new peep' + peep.user_id = 1 + peep.peep_id = 2 + + repo.create(peep) + expect(repo.all_with_user.first.reply_count).to eq 2 + end + end + + context '#find_by_id' do + it 'finds a single peep' do + repo = PeepRepository.new + + peep = repo.find_by_id(4) + expect(peep.message).to eq 'I am tagging Bob' + expect(peep.timestamp).to eq '01 May 2023 16:23:35' + expect(peep.user.name).to eq 'Fred' + expect(peep.user.username).to eq 'freddo' + expect(peep.tags.length).to eq 1 + expect(peep.tags.first.username).to eq 'bob678' + end + end + + context '#get_replies' do + it 'gets a list of replies for a peep' do + repo = PeepRepository.new + replies = repo.get_replies(2) + + expect(replies.length).to eq 1 + expect(replies.first.message).to eq 'This is a reply to the great peep' + expect(replies.first.user.name).to eq 'Bob' + expect(replies.first.user.username).to eq 'bob678' + end + end +end diff --git a/spec/seeds_chitter_test.sql b/spec/seeds_chitter_test.sql new file mode 100644 index 0000000000..88791e27cd --- /dev/null +++ b/spec/seeds_chitter_test.sql @@ -0,0 +1,13 @@ +TRUNCATE TABLE peeps_users, peeps, users RESTART IDENTITY; + +INSERT INTO users (email, password, name, username) VALUES ('hello@example.com', 'password', 'Bob', 'bob678'), +('fred@example.com', '$2a$12$7NbikxOM4OU25bN0CT/lxevYcjDFnJzzReisBY6PUObEvmYEpy3hG', 'Fred', 'freddo'); + +INSERT INTO peeps (message, timestamp, user_id, peep_id) VALUES +('Hello world', '2023-04-28 12:45:05', 1, null), +('This is a great peep', '2023-05-03 16:23:34', 2, null), +('This is a reply to the great peep', '2023-05-04 11:01:25', 1, 2), +('I am tagging Bob', '2023-05-01 16:23:35', 2, null); + +INSERT INTO peeps_users (peep_id, user_id) VALUES +(4, 1); \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 252747d899..51076f9fb2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,9 @@ +require 'database_connection' require 'simplecov' require 'simplecov-console' +DatabaseConnection.connect('chitter_test') + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::Console, # Want a nice code coverage website? Uncomment this next line! diff --git a/spec/user_repository_spec.rb b/spec/user_repository_spec.rb new file mode 100644 index 0000000000..e16f70902d --- /dev/null +++ b/spec/user_repository_spec.rb @@ -0,0 +1,87 @@ +require 'user_repository' + +def reset_users_table + seed_sql = File.read('spec/seeds_chitter_test.sql') + connection = PG.connect({ host: '127.0.0.1', dbname: 'chitter_test' }) + connection.exec(seed_sql) +end + +describe UserRepository do + before(:each) do + reset_users_table + end + + context '#create' do + it 'creates a new user' do + repo = UserRepository.new + user = User.new + user.email = 'email@example.com' + user.password = '123' + user.name = 'Jim' + user.username = 'jimmmmeee' + + repo.create(user) + + new_user_id = repo.log_in('email@example.com', '123') + expect(new_user_id).to eq 3 + end + + it 'returns an error if duplicate email' do + repo = UserRepository.new + user = User.new + user.email = 'fred@example.com' + user.password = '123' + user.name = 'Freddie' + user.username = 'freddie' + + expect { repo.create(user) }.to raise_error(PG::UniqueViolation) + end + + it 'returns an error if duplicate username' do + repo = UserRepository.new + user = User.new + user.email = 'freddie@example.com' + user.password = '123' + user.name = 'Freddie' + user.username = 'freddo' + + expect { repo.create(user) }.to raise_error(PG::UniqueViolation) + end + end + + context '#log_in' do + it 'logs a user in' do + repo = UserRepository.new + user_id = repo.log_in('fred@example.com', '123') + expect(user_id).to eq 2 + end + + it 'returns nil if email is incorrect' do + repo = UserRepository.new + user_id = repo.log_in('fred@gmail.com', '123') + expect(user_id).to eq nil + end + + it 'returns nil if password incorrect' do + repo = UserRepository.new + user_id = repo.log_in('fred@example.com', '12') + expect(user_id).to eq nil + end + end + + context '#tag_users' do + it 'creates a tag for each user tagged' do + repo = UserRepository.new + expect { repo.tag_users(1, 'bob678, freddo') }.not_to raise_error + end + end + + context '#find_by_username' do + it 'finds a user by username' do + repo = UserRepository.new + user = repo.find_by_username('bob678') + expect(user.name).to eq 'Bob' + expect(user.email).to eq 'hello@example.com' + end + end +end diff --git a/views/create_peep.erb b/views/create_peep.erb new file mode 100644 index 0000000000..92906d580c --- /dev/null +++ b/views/create_peep.erb @@ -0,0 +1,17 @@ +
+

Create a new peep

+ +
+ + +

Enter username(s) eg. user1, user2

+

If user doesn't exist they won't be tagged

+
+
+ + +
+ + + Go to Peeps +
diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 0000000000..35540f67f8 --- /dev/null +++ b/views/index.erb @@ -0,0 +1,26 @@ +
+ bird +

Welcome to Chitter!

+ 🐦 Click here to see the peeps + +

Sign up

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
diff --git a/views/layout.erb b/views/layout.erb new file mode 100644 index 0000000000..96f3021c39 --- /dev/null +++ b/views/layout.erb @@ -0,0 +1,30 @@ + + + + + + + Welcome to Chitter! + + + + + + + +
+ Home +
+ <% if session[:user_id] %> +

You are logged in

+ Logout + <% else %> + Sign Up + Log In + <% end %> +
+
+
<%= yield %>
+ + + diff --git a/views/login.erb b/views/login.erb new file mode 100644 index 0000000000..0433904ce0 --- /dev/null +++ b/views/login.erb @@ -0,0 +1,15 @@ +
+

Log in to Chitter

+ +
+
+ + +
+
+ + +
+ +
+
diff --git a/views/login_error.erb b/views/login_error.erb new file mode 100644 index 0000000000..cd030a98c0 --- /dev/null +++ b/views/login_error.erb @@ -0,0 +1,7 @@ +
+

Error

+ +

The username and password do not match

+ + Go back to log in +
diff --git a/views/login_success.erb b/views/login_success.erb new file mode 100644 index 0000000000..48ca701a32 --- /dev/null +++ b/views/login_success.erb @@ -0,0 +1,7 @@ +
+

Success

+

You have successfully logged in!

+ + Go to Peeps + Create a peep +
diff --git a/views/peep.erb b/views/peep.erb new file mode 100644 index 0000000000..6e6d707143 --- /dev/null +++ b/views/peep.erb @@ -0,0 +1,34 @@ +
+ Back +
+
+ <% @peep.tags.each do |tag| %> +

@<%= tag.username %>

+ <% end %> +
+

<%= @peep.message %>

+
+

🐤 <%= @peep.user.username %>

+

name: <%= @peep.user.name %>

+
+

<%= @peep.timestamp %>

+
+ <% if @replies.length > 0 %> +

Replies:

+ <% @replies.each do |reply| %> +
+

<%= reply.message %>

+
+

🐤 <%= reply.user.username %>

+

name: <%= reply.user.name %>

+
+

<%= reply.timestamp %>

+
+ <% end %> <% end %> +
+ + + + +
+
diff --git a/views/peeps.erb b/views/peeps.erb new file mode 100644 index 0000000000..8f8d5a888e --- /dev/null +++ b/views/peeps.erb @@ -0,0 +1,35 @@ +
+

🐦🐦 Chitter Feed 🐦🐦

+ + Create a new peep + + <% if @peeps.empty? %> +

There are no peeps yet.

+ <% else %> + + <% end %> +
diff --git a/views/signup_error.erb b/views/signup_error.erb new file mode 100644 index 0000000000..0a8b9deede --- /dev/null +++ b/views/signup_error.erb @@ -0,0 +1,7 @@ +
+

Error

+ +

The username and email must be unique

+ + Go back to sign up +
diff --git a/views/signup_success.erb b/views/signup_success.erb new file mode 100644 index 0000000000..2728078bc9 --- /dev/null +++ b/views/signup_success.erb @@ -0,0 +1,6 @@ +
+

Success

+

You have successfully signed up for Chitter!

+

Please now log in

+ Log in +