├── tmp └── .gitkeep ├── .ruby-version ├── app ├── aggregates │ ├── .gitkeep │ ├── mixins │ │ └── attributes.rb │ ├── session.rb │ ├── member │ │ ├── tag_list.rb │ │ └── tag.rb │ ├── contact.rb │ ├── registration.rb │ └── member.rb ├── commands │ ├── .gitkeep │ ├── commands.rb │ ├── profile │ │ ├── tag.rb │ │ └── update.rb │ ├── member │ │ ├── add_member.rb │ │ └── invite_member.rb │ ├── registration │ │ ├── confirmation.rb │ │ └── new_registration.rb │ ├── application_command.rb │ ├── application_command_handler.rb │ ├── contact │ │ └── add.rb │ └── session │ │ └── start.rb ├── events │ ├── .gitkeep │ ├── member_added.rb │ ├── member_invited.rb │ ├── member_tag_added.rb │ ├── contact_added.rb │ ├── follower_added.rb │ ├── member_bio_updated.rb │ ├── member_name_updated.rb │ ├── session_started.rb │ ├── registration_confirmed.rb │ ├── confirmation_email_sent.rb │ └── registration_requested.rb ├── reactors │ ├── .gitkeep │ ├── member_generator.rb │ ├── follower.rb │ ├── invitation_mailer.rb │ └── confirmation_mailer.rb ├── projections │ ├── .gitkeep │ ├── updates │ │ ├── query.rb │ │ └── projector.rb │ ├── invitations │ │ └── projector.rb │ ├── contacts │ │ ├── query.rb │ │ └── projector.rb │ └── members │ │ ├── query.rb │ │ └── projector.rb ├── web │ ├── views │ │ ├── error.erb │ │ ├── notification.erb │ │ ├── layout_anonymous.erb │ │ ├── profile_tags.erb │ │ ├── login.erb │ │ ├── updates.erb │ │ ├── contacts.erb │ │ ├── profile_edit.erb │ │ ├── head.erb │ │ ├── register.erb │ │ ├── home.erb │ │ ├── confirmation_success.erb │ │ ├── profile.erb │ │ └── layout_member.erb │ ├── public │ │ └── css │ │ │ ├── fonts │ │ │ ├── materialdesignicons-webfont.eot │ │ │ ├── materialdesignicons-webfont.ttf │ │ │ ├── materialdesignicons-webfont.woff │ │ │ └── materialdesignicons-webfont.woff2 │ │ │ └── application.css │ ├── controllers │ │ ├── web │ │ │ ├── home_controller.rb │ │ │ ├── updates_controller.rb │ │ │ ├── login_controller.rb │ │ │ ├── confirmations_controller.rb │ │ │ ├── registrations_controller.rb │ │ │ ├── profiles_controller.rb │ │ │ ├── my_profiles_controller.rb │ │ │ ├── contacts_controller.rb │ │ │ ├── tags_controller.rb │ │ │ └── web_controller.rb │ │ ├── application_controller.rb │ │ └── api │ │ │ └── api_controller.rb │ ├── helpers │ │ ├── policy_helpers.rb │ │ └── load_helpers.rb │ ├── policies │ │ ├── contact_policy.rb │ │ └── application_policy.rb │ ├── view_models │ │ ├── update.rb │ │ └── profile.rb │ └── server.rb ├── errors.rb └── mail │ └── registration_mail.erb ├── .github ├── linters │ └── .ruby-lint.yml ├── ISSUE_TEMPLATE │ ├── bootstrap_task.md │ └── rfc.md └── workflows │ ├── superlinter.yml │ └── test.yml ├── .gitignore ├── Procfile ├── package.json ├── .rubocop.yml ├── test ├── fixtures │ └── input │ │ ├── harry_potter.json │ │ └── basic_users.jsonl ├── support │ ├── file_helpers.rb │ ├── workflows │ │ ├── adds_contact.rb │ │ ├── base.rb │ │ ├── discovers_member.rb │ │ ├── tags_member.rb │ │ ├── member_logs_in.rb │ │ ├── manage_profile.rb │ │ ├── add_member.rb │ │ └── member_registers.rb │ ├── mail_helpers.rb │ ├── web_test_helpers.rb │ ├── time_helpers.rb │ ├── workflows.rb │ ├── event_helpers.rb │ ├── shared │ │ └── attribute_behaviour.rb │ ├── data_helpers.rb │ └── request_helpers.rb ├── integration │ ├── web │ │ ├── views_profile_test.rb │ │ ├── visitor_lands_on_home_test.rb │ │ ├── member_logs_in_test.rb │ │ ├── contacts_test.rb │ │ ├── member_tags_member_test.rb │ │ ├── manage_profile_test.rb │ │ └── visitor_registers_test.rb │ ├── cli │ │ └── sink_test.rb │ └── api │ │ ├── member_invites_member_test.rb │ │ └── member_authenticates_test.rb ├── commands │ ├── application_command_test.rb │ ├── contact │ │ └── add_test.rb │ ├── session │ │ └── start_test.rb │ └── registration │ │ └── new_registration_test.rb ├── aggregates │ ├── contact_test.rb │ ├── session_test.rb │ ├── member │ │ ├── tag_list_test.rb │ │ └── tag_test.rb │ ├── registration_test.rb │ └── member_test.rb ├── lib │ ├── null_date_test.rb │ ├── aggregate_equality_test.rb │ ├── mail_renderer_test.rb │ └── handle_test.rb ├── web │ ├── view_models │ │ └── profile_test.rb │ └── helpers │ │ └── load_helpers_test.rb └── test_helper.rb ├── lib ├── aggregate_equality.rb ├── null_date.rb ├── mail_renderer.rb └── handle.rb ├── bin ├── console └── sink ├── .env.template ├── yarn.lock ├── app.json ├── config ├── database.rb └── environment.rb ├── config.ru ├── LICENSE ├── Gemfile ├── Makefile ├── Rakefile ├── README.md └── Gemfile.lock /tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.2 2 | -------------------------------------------------------------------------------- /app/aggregates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/commands/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/events/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/reactors/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/projections/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/linters/.ruby-lint.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - ../../.rubocop.yml 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.test 3 | coverage/* 4 | node_modules/ 5 | 6 | tmp/* 7 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./script/server 2 | processors: bundle exec rake run_processors 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "bulma": "^0.9.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/web/views/error.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= message %> 4 |
5 | -------------------------------------------------------------------------------- /app/events/member_added.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A Member was added 5 | MemberAdded = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Metrics/BlockLength: 5 | ExcludedMethods: ['describe', 'context', 'define', 'factory', 'namespace'] 6 | -------------------------------------------------------------------------------- /app/events/member_invited.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A Member was invited 5 | class MemberInvited < EventSourcery::Event 6 | end 7 | -------------------------------------------------------------------------------- /app/events/member_tag_added.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A Member was Tagged 5 | MemberTagAdded = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/web/views/notification.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= message[1] %> 4 |
5 | -------------------------------------------------------------------------------- /app/events/contact_added.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A Contact was added to a Member 5 | ContactAdded = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/events/follower_added.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A follower was added to a member 5 | FollowerAdded = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/events/member_bio_updated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A profile bio was updated 5 | MemberBioUpdated = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/events/member_name_updated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A profile bio was updated 5 | MemberNameUpdated = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/events/session_started.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A Logged in session was started 5 | SessionStarted = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/web/public/css/fonts/materialdesignicons-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flockingbird/roost/HEAD/app/web/public/css/fonts/materialdesignicons-webfont.eot -------------------------------------------------------------------------------- /app/web/public/css/fonts/materialdesignicons-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flockingbird/roost/HEAD/app/web/public/css/fonts/materialdesignicons-webfont.ttf -------------------------------------------------------------------------------- /app/web/public/css/fonts/materialdesignicons-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flockingbird/roost/HEAD/app/web/public/css/fonts/materialdesignicons-webfont.woff -------------------------------------------------------------------------------- /app/web/public/css/fonts/materialdesignicons-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flockingbird/roost/HEAD/app/web/public/css/fonts/materialdesignicons-webfont.woff2 -------------------------------------------------------------------------------- /app/events/registration_confirmed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A Registration is confirmed 5 | RegistrationConfirmed = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/events/confirmation_email_sent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A confirmation email has been sent 5 | ConfirmationEmailSent = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /app/events/registration_requested.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # A visitor has registered: reguested a new registration 5 | RegistrationRequested = Class.new(EventSourcery::Event) 6 | -------------------------------------------------------------------------------- /test/fixtures/input/harry_potter.json: -------------------------------------------------------------------------------- 1 | { 2 | "bio": "Fought a snakey guy, now proud father and civil servant", 3 | "name": "Harry Potter", 4 | "email": "hpotter@example.org", 5 | "username": "hpotter", 6 | "password": "caput draconis" 7 | } 8 | -------------------------------------------------------------------------------- /app/web/controllers/web/home_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Renders homepage. 6 | # Also acts as fallback for static files 7 | class HomeController < WebController 8 | get('/') { erb :home } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/aggregate_equality.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Allows aggregates and aggregate-alikes (like decorators) to match 5 | # the objects 6 | module AggregateEquality 7 | def ==(other) 8 | return false if id.nil? 9 | 10 | id == other.id 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/web/views/layout_anonymous.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= erb(:head) %> 5 | 6 | 7 |
8 |
9 | <%= notifications %> 10 | <%= yield %> 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH << '.' 5 | 6 | require 'bundler/setup' 7 | require_relative '../config/environment.rb' 8 | require_relative '../config/database.rb' 9 | 10 | require 'awesome_print' 11 | require 'pry' 12 | 13 | Pry.start 14 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | export PORT=export PORT 2 | # Postgres Config. See https://www.postgresql.org/docs/9.1/libpq-envars.html 3 | export DB_USER=export DB_USER 4 | export DB_HOST=export DB_HOST 5 | export DB_PASSWORD=export DB_PASSWORD 6 | export DB_NAME=export DB_NAME 7 | export DB_PORT=export DB_PORT 8 | export DATABASE_URL=export DATABASE_URL 9 | -------------------------------------------------------------------------------- /app/web/helpers/policy_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/web/policies/contact_policy' 4 | 5 | ## 6 | # Holds the may_x? helpers for views and controllers, that wrap the policies 7 | module PolicyHelpers 8 | def may_add_contact? 9 | ContactPolicy.new(current_member, contact).add? 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/support/file_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Helpers for testing against files 5 | module FileHelpers 6 | def assert_file_contains(file, string) 7 | contents = File.read(file) 8 | message = %(Expected file "#{file}" to contain "#{string}") 9 | assert_includes(contents, string, message) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/web/policies/contact_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Policy for the Contact Aggregate 5 | class ContactPolicy < ApplicationPolicy 6 | def add? 7 | !anon? && !self? 8 | end 9 | 10 | private 11 | 12 | def anon? 13 | actor.null? 14 | end 15 | 16 | def self? 17 | actor == aggregate 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/fixtures/input/basic_users.jsonl: -------------------------------------------------------------------------------- 1 | { "bio": "Fought a snakey guy, now proud father and civil servant", "name": "Harry Potter", "email": "hpotter@example.org", "password": "12345678", "username": "hpotter" } 2 | { "bio":"In congue. Etiam justo. Etiam pretium iaculis justo.","name":"Ron Weasly","email":"ron@example.org", "password": "12345678", "username": "ron" } 3 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | bulma@^0.9.1: 6 | version "0.9.1" 7 | resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.9.1.tgz#2bf0e25062a22166db5c92e8c3dcb4605ab040d8" 8 | integrity sha512-LSF69OumXg2HSKl2+rN0/OEXJy7WFEb681wtBlNS/ulJYR27J3rORHibdXZ6GVb/vyUzzYK/Arjyh56wjbFedA== 9 | -------------------------------------------------------------------------------- /app/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # HTTP 400 bad Request 5 | BadRequest = Class.new(StandardError) 6 | ## 7 | # HTTP 404 not found 8 | NotFound = Class.new(StandardError) 9 | ## 10 | # HTTP 401 Unauthorized 11 | Unauthorized = Class.new(StandardError) 12 | ## 13 | # HTTP 422 bad Request 14 | UnprocessableEntity = Class.new(StandardError) 15 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Roost", 3 | "description": "Roost, an EventSourcery app", 4 | "keywords": ["Roost", "ruby", "event_sourcery", "cqrs"], 5 | "formation": { 6 | "web": { 7 | "quantity": 1 8 | }, 9 | "processors": { 10 | "quantity": 1 11 | } 12 | }, 13 | "scripts": { 14 | "postdeploy": "bundle exec rake db:migrate" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /config/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | EventSourcery::Postgres.configure do |config| 4 | database = Sequel.connect(Roost.config.database_url) 5 | 6 | # NOTE: Often we choose to split our events and projections into separate 7 | # databases. For the purposes of this example we'll use one. 8 | config.event_store_database = database 9 | config.projections_database = database 10 | end 11 | -------------------------------------------------------------------------------- /app/web/policies/application_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Main policy to allow determining wether a member may perform an action on 5 | # an Aggregate. 6 | # Called from a Controller to determine authorization for a certain 7 | # command. Called from a View (or controller) to determine toggles for UI 8 | # elements. 9 | ApplicationPolicy = Struct.new(:actor, :aggregate, :command) do 10 | end 11 | -------------------------------------------------------------------------------- /app/mail/registration_mail.erb: -------------------------------------------------------------------------------- 1 | Time to verify you email 2 | 3 | You've received this message because your email address has been registered 4 | with Flockingbird. Please verify yourself and confirm you email by cliking 5 | below. 6 | 7 | <%= confirmation_url %> 8 | 9 | If you did not sign up, we're very sorry. Simply ignore this email and don't 10 | click above link, you will then not receive any more emails from us. 11 | -------------------------------------------------------------------------------- /app/projections/updates/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Projections 4 | module Updates 5 | ## 6 | # Looks up Updates in projection 7 | class Query 8 | def self.for_member(member_id) 9 | collection.where(for: member_id) 10 | end 11 | 12 | def self.collection 13 | @collection ||= Roost.projections_database[:updates] 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/workflows/adds_contact.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Workflow to add a contact from a profile of a user 6 | class AddsContact < Base 7 | def contact_added 8 | click_icon('account-plus') 9 | 10 | process_events(%w[contact_added]) 11 | end 12 | 13 | private 14 | 15 | def steps 16 | %i[contact_added].freeze 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/web/view_models/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewModels 4 | ## 5 | # A status Update view model 6 | class Update < OpenStruct 7 | def self.from_collection(collection) 8 | collection.map { |attrs| new(attrs) } 9 | end 10 | 11 | def posted_on 12 | posted_at.to_date 13 | end 14 | 15 | def posted_at 16 | super || NullDateTime.new('never') 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/projections/invitations/projector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Projections 4 | module Invitations 5 | ## 6 | # Stores Members in their distinct query table 7 | class Projector 8 | include EventSourcery::Postgres::Projector 9 | 10 | projector_name :invitations 11 | 12 | table :invitations do 13 | column :name, :text 14 | column :email, :text 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/aggregates/mixins/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aggregates 4 | ## 5 | # Generic aggregate behaviour 6 | module Attributes 7 | def write_attributes(new_attributes) 8 | attributes.merge!(new_attributes.transform_keys(&:to_sym)) 9 | end 10 | 11 | def attributes 12 | @attributes ||= Hash.new('').merge(aggregate_id: id) 13 | end 14 | 15 | def to_h 16 | attributes 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/projections/contacts/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Projections 4 | module Contacts 5 | # Query the Contacts projection; join to enrich with Members 6 | class Query 7 | def self.for_member(id) 8 | collection.where(owner_id: id).inner_join(:members, handle: :handle) 9 | end 10 | 11 | def self.collection 12 | @collection ||= Roost.projections_database[:contacts] 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/web/controllers/web/updates_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles my my updates view, notifications 6 | class UpdatesController < WebController 7 | get '/updates' do 8 | requires_authorization 9 | updates = ViewModels::Update.from_collection( 10 | Projections::Updates::Query.for_member(member_id) 11 | ) 12 | erb :updates, layout: :layout_member, locals: { updates: updates } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/web/helpers/load_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Loads aggregates from repo with fallbacks to nullobjects 5 | module LoadHelpers 6 | def load(aggregate_class, aggregate_id) 7 | return unless aggregate_id 8 | 9 | Roost.repository.load(aggregate_class, aggregate_id) 10 | end 11 | 12 | def decorate(object, decorator_class, decorator_null_class) 13 | return decorator_null_class.new unless object 14 | 15 | decorator_class.new(object) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/integration/web/views_profile_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a member using the web-app 7 | # When another member has a profile 8 | # And I visit that profile 9 | # Then I see the public information by that other member 10 | class ViewsProfileTest < Minitest::WebSpec 11 | it 'cannot view nonexisting members' do 12 | visit '/m/@doesnotexist@example.com' 13 | assert_equal(page.status_code, 404) 14 | assert_content(page, 'not found') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/commands/commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Commands namespace 5 | # Has some factory methods for commands 6 | module Commands 7 | ## 8 | # Helper to run the common pattern of one handler per command, in the 9 | # same namespace. 10 | def self.handle(root, name, params) 11 | command = Object.const_get("Commands::#{root}::#{name}::Command") 12 | .new(params) 13 | Object.const_get("Commands::#{root}::#{name}::CommandHandler") 14 | .new(command: command).handle 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/aggregates/session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aggregates 4 | ## 5 | # A +Session+ represents a person who is logged in. 6 | class Session 7 | include EventSourcery::AggregateRoot 8 | 9 | attr_reader :member_id 10 | 11 | apply SessionStarted do |event| 12 | @member_id = event.body['member_id'] 13 | end 14 | 15 | def start(payload) 16 | apply_event( 17 | SessionStarted, 18 | aggregate_id: id, 19 | body: payload 20 | ) 21 | self 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/commands/profile/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Commands 4 | module Profile 5 | module Tag 6 | ## 7 | # Command to add a Tag 8 | class Command < ApplicationCommand 9 | end 10 | 11 | ## 12 | # Handler for Profile::Tag::Command 13 | class CommandHandler < ApplicationCommandHandler 14 | def aggregate_class 15 | Aggregates::Member 16 | end 17 | 18 | def aggregate_method 19 | :add_tag 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/aggregates/member/tag_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aggregates 4 | class Member 5 | ## 6 | # A +TagList+ is an ordered Set. Ensuring tags with same names are merged. 7 | class TagList < Array 8 | def <<(other) 9 | if include?(other) 10 | find_original(other).merge(other) 11 | else 12 | super(other) 13 | end 14 | end 15 | 16 | private 17 | 18 | def find_original(other) 19 | find { |tag| tag == other } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/null_date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Generic Null object 5 | class NullObject 6 | attr_reader :placeholder 7 | def initialize(placeholder = '') 8 | @placeholder = placeholder 9 | end 10 | 11 | def to_s 12 | placeholder 13 | end 14 | 15 | def null? 16 | true 17 | end 18 | end 19 | 20 | ## 21 | # Null Object for DateTimes 22 | class NullDateTime < NullObject 23 | def to_date 24 | NullDate.new(placeholder) 25 | end 26 | end 27 | 28 | # ## 29 | # Null Object for Dates 30 | class NullDate < NullObject 31 | end 32 | -------------------------------------------------------------------------------- /test/commands/application_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # Test the top level command 7 | class ApplicationCommandTest < Minitest::Spec 8 | describe 'with aggregate_id' do 9 | let(:params) { { aggregate_id: SecureRandom.uuid } } 10 | 11 | it 'is valid' do 12 | assert(subject.validate) 13 | end 14 | end 15 | 16 | describe 'without aggregate_id' do 17 | end 18 | 19 | private 20 | 21 | def subject 22 | @subject ||= Commands::ApplicationCommand.new(params) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/support/mail_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Helpers for Mail deliveries and specification 5 | module MailHelpers 6 | protected 7 | 8 | def deliveries 9 | Mail::TestMailer.deliveries 10 | end 11 | 12 | def assert_mail_deliveries(amount) 13 | actual = deliveries.length 14 | subjects = deliveries.map(&:subject).join(', ') 15 | 16 | assert_equal( 17 | amount, 18 | actual, 19 | "Expected #{amount} mails delivered, got #{actual}."\ 20 | " Mails where: #{subjects}" 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/reactors/member_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Reactors 4 | ## 5 | # Generates a MemberAdded Event. 6 | class MemberGenerator 7 | include EventSourcery::Postgres::Reactor 8 | 9 | processor_name :member_generator 10 | emits_events MemberAdded 11 | 12 | process RegistrationConfirmed do |event| 13 | emit_event( 14 | MemberAdded.new( 15 | aggregate_id: SecureRandom.uuid, 16 | body: event.body, 17 | causation_id: event.id 18 | ) 19 | ) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/web/views/profile_tags.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 | 7 |
8 | 9 |
10 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /test/support/workflows/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Common workflow generics 6 | class Base < SimpleDelegator 7 | attr_reader :test_obj, :form_attributes 8 | 9 | def initialize(test_obj, form_attributes = {}) 10 | @form_attributes = form_attributes 11 | super(test_obj) 12 | end 13 | 14 | def upto(final_step) 15 | retval = nil 16 | 17 | steps[0..steps.index(final_step)].each do |current_step| 18 | retval = send(current_step) 19 | end 20 | 21 | retval 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/commands/member/add_member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/member' 4 | 5 | module Commands 6 | module Member 7 | module AddMember 8 | ## 9 | # Command to add a new +Member+ 10 | class Command < ApplicationCommand 11 | end 12 | 13 | # Handler for AddMember::Command 14 | class CommandHandler < ApplicationCommandHandler 15 | def aggregate_class 16 | Aggregates::Member 17 | end 18 | 19 | def aggregate_method 20 | :add_member 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/reactors/follower.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Reactors 4 | ## 5 | # Adds an actor to a members' followers on various Events 6 | class Follower 7 | include EventSourcery::Postgres::Reactor 8 | 9 | processor_name :follower 10 | 11 | emits_events FollowerAdded 12 | 13 | process MemberTagAdded do |event| 14 | emit_event( 15 | FollowerAdded.new( 16 | aggregate_id: event.aggregate_id, 17 | body: { follower_id: event.body['author_id'] }, 18 | causation_id: event.uuid 19 | ) 20 | ) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/web/views/login.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |
7 |
8 |
9 | 10 |
11 | 12 |
13 |
14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/web/views/updates.erb: -------------------------------------------------------------------------------- 1 | <%- updates.each do |update| %> 2 |
3 |
4 |

5 | 6 |

7 |
8 |
9 |
10 |

11 | <%= update.author %> 12 | <%= update.posted_on %> 13 |
14 | <%= update.text %> 15 |

16 |
17 |
18 |
19 | <% end %> 20 | -------------------------------------------------------------------------------- /app/commands/member/invite_member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/member' 4 | 5 | module Commands 6 | module Member 7 | module InviteMember 8 | ## 9 | # Command to invite a new +Member+. 10 | class Command < ApplicationCommand 11 | end 12 | 13 | # Handler for InviteMember::Command 14 | class CommandHandler < ApplicationCommandHandler 15 | def aggregate_class 16 | Aggregates::Member 17 | end 18 | 19 | def aggregate_method 20 | :invite_member 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/web/controllers/web/login_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles login 6 | class LoginController < WebController 7 | get('/login') { erb :login, layout: :layout_anonymous } 8 | 9 | post '/login' do 10 | session_aggregate = Commands.handle('Session', 'Start', post_params) 11 | session[:member_id] = session_aggregate.member_id 12 | flash[:success] = 'Login Successful' 13 | redirect '/contacts' 14 | end 15 | 16 | private 17 | 18 | def post_params 19 | params.slice('username', 'password') 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/commands/registration/confirmation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/registration' 4 | 5 | module Commands 6 | module Registration 7 | module Confirm 8 | ## 9 | # Command to invite a new +Member+. 10 | class Command < ApplicationCommand 11 | end 12 | 13 | # Handler for Confirm::Command 14 | class CommandHandler < ApplicationCommandHandler 15 | def aggregate_class 16 | Aggregates::Registration 17 | end 18 | 19 | def aggregate_method 20 | :confirm 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << '.' 4 | 5 | require 'sinatra/base' 6 | 7 | require 'config/environment' 8 | require 'config/database' 9 | require 'app/web/server' 10 | 11 | use Web::HomeController 12 | use Web::ProfilesController 13 | use Web::TagsController 14 | 15 | use Web::LoginController 16 | # TODO: change from RPC alike "register" to "registration" 17 | use Web::RegistrationsController 18 | use Web::ConfirmationsController 19 | 20 | use Web::MyProfilesController 21 | use Web::ContactsController 22 | use Web::UpdatesController 23 | 24 | use Api::ApiController 25 | 26 | run ApplicationController 27 | -------------------------------------------------------------------------------- /lib/mail_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'erb' 4 | 5 | ## 6 | # Mail Renderer: renders ERB templates from app/mail into mail 7 | # body 8 | class MailRenderer 9 | def initialize(templates_root: 'app/mail/', renderer: ERB) 10 | @templates_root = templates_root 11 | @renderer = renderer 12 | end 13 | 14 | def render(template, locals = {}) 15 | renderer.new(file(template)).result_with_hash(locals) 16 | end 17 | 18 | private 19 | 20 | attr_reader :templates_root, :renderer 21 | 22 | def file(template) 23 | File.read(File.join("#{templates_root}/#{template}.erb")) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/web/controllers/web/confirmations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles the registration confirmation links 6 | class ConfirmationsController < WebController 7 | get '/confirmation/:aggregate_id' do 8 | Commands.handle('Registration', 'Confirm', 9 | aggregate_id: params[:aggregate_id]) 10 | erb :confirmation_success 11 | rescue Aggregates::Registration::AlreadyConfirmedError 12 | render_error( 13 | 'Could not confirm. Maybe the link in the email expired, or was'\ 14 | ' already used?' 15 | ) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/web_test_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Helpers to interact with web-pages 5 | module WebTestHelpers 6 | ## 7 | # Click on an icon by its description 8 | def click_icon(descriptor) 9 | find(icon_selector(descriptor)).find(:xpath, '../..').click 10 | end 11 | 12 | def icon_selector(descriptor) 13 | "span.icon i.mdi-#{descriptor}" 14 | end 15 | 16 | ## 17 | # Find the main menu 18 | def main_menu(identifier) 19 | find('nav.navbar a', text: identifier) 20 | end 21 | 22 | ## 23 | # Find a status message 24 | def flash(type) 25 | find(".notification.is-#{type}") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/workflows/discovers_member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/projections/members/query' 4 | 5 | module Workflows 6 | ## 7 | # Workflow to discover members on this instance 8 | class DiscoversMember < Base 9 | def profile_visited 10 | # TODO: replace by actual searching, or clicking through existing contacts 11 | # or some other simple way that a user might realisticly discover members 12 | visit "/m/@#{form_attributes[:username]}@example.com" 13 | assert_equal(200, page.status_code) 14 | end 15 | 16 | private 17 | 18 | def steps 19 | %i[profile_visited].freeze 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/aggregates/contact.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aggregates 4 | ## 5 | # A +Contact+ is a profile, member, remote member, or unregistered 6 | # that a member has in her contacts list. 7 | class Contact 8 | include EventSourcery::AggregateRoot 9 | 10 | def initialize(*arguments) 11 | @added = false 12 | super(*arguments) 13 | end 14 | 15 | apply ContactAdded do 16 | @added = true 17 | end 18 | 19 | def add(payload) 20 | raise UnprocessableEntity, 'Contact is already added' if @added 21 | 22 | apply_event(ContactAdded, aggregate_id: id, body: payload) 23 | self 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/projections/members/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Projections 4 | module Members 5 | # Query the Members projection with helpers that return 6 | # Sequel collection objects. 7 | class Query 8 | def self.find_by(username:) 9 | collection.first(username: username) 10 | end 11 | 12 | def self.find(id) 13 | collection[member_id: id] 14 | end 15 | 16 | def self.aggregate_id_for(handle) 17 | (collection.first(handle: handle.to_s) || {}).fetch(:member_id, nil) 18 | end 19 | 20 | def self.collection 21 | @collection ||= Roost.projections_database[:members] 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/workflows/tags_member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Workflow to tag another members' profile. 6 | class TagsMember < Base 7 | def tag_added 8 | within '.tags' do 9 | click_icon('plus') 10 | end 11 | 12 | within 'form' do 13 | fill_in 'Add your own', with: form_attributes[:tag] 14 | click_icon('plus') 15 | end 16 | 17 | process_events(%w[member_tag_added follower_added]) 18 | end 19 | 20 | def form_attributes 21 | { 22 | tag: 'friend' 23 | }.merge(@form_attributes) 24 | end 25 | 26 | private 27 | 28 | def steps 29 | %i[tag_added].freeze 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/aggregates/contact_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module Aggregates 6 | ## 7 | # Unit test for the more complex logic in Contact Aggregate 8 | class ContactTest < Minitest::Spec 9 | subject do 10 | Aggregates::Contact.new(fake_uuid(Aggregates::Contact, 1), []) 11 | end 12 | 13 | let(:owner_id) { fake_uuid(Aggregates::Member, 1) } 14 | 15 | describe '#add' do 16 | let(:payload) { { owner_id: owner_id, handle: '@ron@example.org' } } 17 | 18 | it 'can only be added once' do 19 | subject.add(payload) 20 | assert_raises(UnprocessableEntity) do 21 | subject.add(payload) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/web/views/contacts.erb: -------------------------------------------------------------------------------- 1 | <%- content_for(:title, "Your contacts") %> 2 |

Your contacts

3 | <%- contacts.each do |contact| %> 4 |
5 |
6 |

7 | 8 |

9 |
10 |
11 |
12 |

13 | <%= contact.name %> 14 | <%= contact.handle %> 15 | <%= contact.updated_on %> 16 |
17 | <%= contact.bio %> 18 |

19 |
20 |
21 |
22 | <% end %> 23 | -------------------------------------------------------------------------------- /test/support/workflows/member_logs_in.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Workflow to log in 6 | class MemberLogsIn < Base 7 | def logged_in 8 | visit_login 9 | 10 | fill_in('Username', with: form_attributes[:username]) 11 | fill_in('Password', with: form_attributes[:password]) 12 | click_button('Login') 13 | 14 | page 15 | end 16 | 17 | def form_attributes 18 | { 19 | username: 'hpotter', 20 | password: 'caput draconis' 21 | }.merge(super) 22 | end 23 | 24 | private 25 | 26 | def visit_login 27 | visit '/' 28 | click_link 'Login' 29 | end 30 | 31 | def steps 32 | %i[logged_in].freeze 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/handle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Handles the Handles (hehehe). 5 | # Parses and formats consistently @harry@example.org alike handles over domains 6 | # and into usenames and domains 7 | class Handle 8 | attr_reader :username, :uri 9 | 10 | def initialize(username, uri = Roost.config.web_url) 11 | @uri = uri 12 | @username = username 13 | end 14 | 15 | def self.parse(handle) 16 | uri = URI.parse("http://#{handle.gsub(/^@/, '')}") 17 | new(uri.user, URI::HTTP.build(host: uri.host).to_s) 18 | end 19 | 20 | def domain 21 | URI.parse(uri).host 22 | end 23 | 24 | def to_s 25 | "@#{username}@#{domain}" 26 | end 27 | 28 | def ==(other) 29 | username == other.username && uri == other.uri 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/lib/null_date_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'lib/null_date' 5 | 6 | ## 7 | # Test the Null Objects 8 | class NullObjectTest < Minitest::Spec 9 | let(:subject) { NullObject.new('[empty]') } 10 | 11 | describe 'to_s' do 12 | it 'returns the placeholder' do 13 | assert_equal(subject.to_s, '[empty]') 14 | end 15 | end 16 | 17 | it 'is always null?' do 18 | assert(subject.null?) 19 | end 20 | end 21 | 22 | ## 23 | # Test the Null Dates 24 | class NullDateTimeTest < Minitest::Spec 25 | let(:subject) { NullDateTime.new('[empty]') } 26 | 27 | describe 'to_date' do 28 | it 'returns a NullDate placeholder' do 29 | assert_equal(subject.to_date.to_s, '[empty]') 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/web/views/profile_edit.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | 6 |
7 | 8 |
9 |
10 |
11 | 12 |
13 | 14 |
15 |
16 |

Your name and bio are always public, anyone can see it.

17 |
18 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /test/integration/web/visitor_lands_on_home_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a visitor 7 | # When I visit the homepage 8 | # Then I see that I can register or login 9 | # So that it is clear what I can do with the app. 10 | class VisitorLandsOnHomeTest < Minitest::WebSpec 11 | describe 'visitor_lands_on_home' do 12 | before do 13 | visit '/' 14 | end 15 | 16 | it 'has a title and subtitle' do 17 | assert_title('Flockingbird') 18 | assert_content('Flockingbird') 19 | assert_content('Manage your business network.') 20 | end 21 | 22 | it 'has two buttons' do 23 | assert_link('Register', class: ['button']) 24 | assert_link('Login', class: 'button') 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/web/views/head.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | Flockingbird - <%= yield_content :title %> 4 | 5 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /app/web/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sinatra/reloader' if Roost.development? 4 | 5 | Dir.glob("#{__dir__}/../projections/**/query.rb").sort.each { |f| require f } 6 | Dir.glob("#{__dir__}/../commands/**/*.rb").sort.each { |f| require f } 7 | Dir.glob("#{__dir__}/view_models/*.rb").sort.each { |f| require f } 8 | 9 | require_relative 'policies/application_policy' 10 | Dir.glob("#{__dir__}/../web/policies/*.rb").sort.each { |f| require f } 11 | 12 | Dir.glob("#{__dir__}/helpers/*.rb").sort.each { |f| require f } 13 | 14 | require_relative 'controllers/application_controller' 15 | require_relative 'controllers/web/web_controller' 16 | require_relative 'controllers/api/api_controller' 17 | 18 | Dir.glob("#{__dir__}/controllers/**/*controller.rb").sort.each { |f| require f } 19 | -------------------------------------------------------------------------------- /app/web/views/register.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |
7 |
8 |
9 | 10 |
11 | 12 |
13 |
14 |
15 | 16 |
17 | 18 |
19 |
20 |
21 | 22 |
23 |
24 | -------------------------------------------------------------------------------- /test/lib/aggregate_equality_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # Test Mock 7 | class TestFakeAggregate 8 | include AggregateEquality 9 | attr_reader :id, :name 10 | 11 | def initialize(id, name) 12 | @id = id 13 | @name = name 14 | end 15 | end 16 | 17 | ## 18 | # Test the AggregateEquality module 19 | class AggregateEqualityTest < Minitest::Spec 20 | it 'is equal when ids are equal' do 21 | id = fake_uuid(Aggregates::Member, 1) 22 | assert_equal( 23 | TestFakeAggregate.new(id, 'harry'), 24 | TestFakeAggregate.new(id, 'ron') 25 | ) 26 | end 27 | 28 | it 'it not equal when both ids are nil' do 29 | refute_equal( 30 | TestFakeAggregate.new(nil, 'harry'), 31 | TestFakeAggregate.new(nil, 'ron') 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/web/controllers/web/registrations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles Registrations 6 | class RegistrationsController < WebController 7 | get('/register') { erb :register, layout: :layout_anonymous } 8 | 9 | post '/register' do 10 | Commands.handle('Registration', 'NewRegistration', post_params) 11 | flash[:success] = 'Registration email sent. Please check your spam 12 | folder too.' 13 | redirect '/' 14 | rescue Aggregates::Registration::EmailAlreadySentError 15 | render_error( 16 | 'Emailaddress is already registered. Do you want to login instead?' 17 | ) 18 | end 19 | 20 | private 21 | 22 | def post_params 23 | params.slice('username', 'password', 'email') 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/web/views/home.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= erb(:head) %> 5 | 6 | 7 |
8 | <%= notifications %> 9 |
10 |
11 |

12 | Flockingbird 13 |

14 |

15 | Manage your business network. Decentralised, and privacy friendly. 16 |

17 |
18 |
19 |
20 |
21 | Register 22 | Login 23 |
24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /app/projections/members/projector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Projections 4 | module Members 5 | ## 6 | # Stores Members in their distinct query table 7 | class Projector 8 | include EventSourcery::Postgres::Projector 9 | 10 | projector_name :members 11 | 12 | table :members do 13 | column :member_id, 'UUID NOT NULL' 14 | column :handle, :text, null: false 15 | column :username, :text 16 | column :password, :text 17 | end 18 | 19 | project MemberAdded do |event| 20 | username = event.body['username'] 21 | 22 | table.insert( 23 | member_id: event.aggregate_id, 24 | handle: Handle.new(username).to_s, 25 | username: username, 26 | password: event.body['password'] 27 | ) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/aggregates/member/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aggregates 4 | class Member 5 | ## 6 | # A +Tag+ is an attribute on a +Member+. Each member has 0-N tags. Each tag 7 | # has 0-N members 8 | class Tag 9 | attr_reader :name 10 | 11 | def initialize(name, author) 12 | @name = name 13 | @authors = Set[author] 14 | end 15 | 16 | def slug 17 | name.downcase 18 | end 19 | 20 | def authors 21 | @authors.to_a 22 | end 23 | 24 | def by?(expected_author) 25 | @authors.include?(expected_author) 26 | end 27 | 28 | def ==(other) 29 | return if other.nil? 30 | 31 | name == other.name 32 | end 33 | 34 | def merge(other) 35 | @authors += other.authors 36 | self 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/aggregates/session_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module Aggregates 6 | ## 7 | # Unit test for session aggregate root 8 | class SessionTest < Minitest::Spec 9 | let(:member_id) { fake_uuid(Aggregates::Member, 1) } 10 | let(:payload) do 11 | { 12 | 'username' => 'hpotter', 13 | 'password' => 'hashed', 14 | 'member_id' => member_id 15 | } 16 | end 17 | 18 | subject { Aggregates::Session.new(fake_uuid(Aggregates::Session, 1), []) } 19 | 20 | describe '#start' do 21 | before { subject.start(payload) } 22 | it 'emits a session_started event' do 23 | assert_includes(subject.changes.map(&:class), SessionStarted) 24 | end 25 | 26 | it 'sets member_id' do 27 | assert_equal(subject.member_id, member_id) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bootstrap_task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Task 3 | about: Use this template to create a new task during the bootstrapping phase 4 | title: "[TASK DESCRIPTOIN (actionable)]" 5 | labels: task 6 | assignees: 7 | --- 8 | 9 | One line summary of the task. Actionable: something that someone can do. This is the title too. 10 | 11 | > E.g. Make a foo that provides bar (and not: there should be a foo that provides a bar, which is a goal, not a task). 12 | 13 | 14 | ## Details 15 | 16 | Concise extra details, only when the one line summary is not enough (it probably is). 17 | 18 | ## Deliverable 19 | 20 | * Try to describe what the finished task offers. 21 | * These are the points that someone can go through, in order to see whether the task is finished. 22 | 23 | E.g. 24 | * Integration tests that show how the bar is provided by the foo. 25 | * A foo-page that shows the bar. 26 | * A link to the foo-page. 27 | -------------------------------------------------------------------------------- /app/commands/application_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Commands 4 | ## 5 | # Base Application Command. 6 | class ApplicationCommand 7 | attr_reader :aggregate_id, :payload 8 | 9 | def initialize(params) 10 | @aggregate_id = params.delete(:aggregate_id) 11 | @payload = params # Select the parameters you want to allow 12 | end 13 | 14 | def validate 15 | raise ArgumentError, 'expected aggregate_id to be set' unless aggregate_id 16 | 17 | true 18 | end 19 | 20 | protected 21 | 22 | def uuid_v5 23 | if aggregate_id_name.empty? 24 | '' 25 | else 26 | UUIDTools::UUID.sha1_create( 27 | aggregate_id_namespace, 28 | aggregate_id_name 29 | ).to_s 30 | end 31 | end 32 | 33 | def raise_bad_request(message) 34 | raise BadRequest, message 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/workflows/manage_profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Workflow to manage the profile of currently logged in member. 6 | class ManageProfile < Base 7 | def profile_visited 8 | main_menu('My profile').click 9 | page 10 | end 11 | 12 | def profile_edit_visited 13 | click_icon('pencil') 14 | page 15 | end 16 | 17 | def bio_updated 18 | fill_in('name', with: name) 19 | fill_in('bio', with: bio) 20 | click_button('Update') 21 | 22 | process_events(%w[member_bio_updated member_name_updated]) 23 | page 24 | end 25 | 26 | private 27 | 28 | def name 29 | form_attributes[:name] 30 | end 31 | 32 | def bio 33 | form_attributes[:bio] 34 | end 35 | 36 | def steps 37 | %i[profile_visited profile_edit_visited bio_updated].freeze 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/web/view_models/profile_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'app/web/view_models/profile' 5 | 6 | ## 7 | # Test Generic decorator spec 8 | # TODO: move into own lib superclass when appropriate 9 | class ViewModelTest < Minitest::Spec 10 | it 'handles build with nil as null?' do 11 | assert(ViewModels::Profile.build(nil).null?) 12 | end 13 | 14 | it 'handles build with attributes hash' do 15 | view_model = ViewModels::Profile.build({ name: 'Harry' }) 16 | assert_equal('Harry', view_model.name) 17 | end 18 | 19 | it 'handles build with aggregate on aggregate_id' do 20 | id = fake_uuid(Aggregates::Member, 1) 21 | view_model = ViewModels::Profile.build(OpenStruct.new(aggregate_id: id)) 22 | assert_equal(view_model.aggregate_id, id) 23 | end 24 | end 25 | 26 | ## 27 | # Test Profile view model implementation 28 | class ViewModelProfileTest < Minitest::Spec 29 | end 30 | -------------------------------------------------------------------------------- /test/support/workflows/add_member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Worflow to add a member through the database/events 6 | class AddMember < Base 7 | include EventHelpers 8 | 9 | def call 10 | create_events 11 | process_events(%w[member_added]) 12 | end 13 | 14 | def create_events 15 | command = Commands::Member::AddMember::Command.new(member_attributes) 16 | Commands::Member::AddMember::CommandHandler.new(command: command).handle 17 | end 18 | 19 | def member_name 20 | 'Harry Potter' 21 | end 22 | 23 | def member_email 24 | 'harry@example.com' 25 | end 26 | 27 | def aggregate_id 28 | @aggregate_id ||= SecureRandom.uuid 29 | end 30 | 31 | private 32 | 33 | def member_attributes 34 | { 35 | aggregate_id: aggregate_id, 36 | name: member_name, 37 | email: member_email 38 | } 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/web/public/css/application.css: -------------------------------------------------------------------------------- 1 | .is-vertical-center { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .is-horizontal-center { 7 | justify-content: center; 8 | } 9 | 10 | .navbar, 11 | .navbar-start { 12 | display: flex; 13 | } 14 | 15 | .navbar .navbar-item { 16 | flex-grow: 1; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | } 21 | 22 | .masthead { 23 | background-image: url('https://placekitten.com/400/300'); 24 | background-repeat: no-repeat; 25 | background-position: top; 26 | background-attachment: fixed; 27 | background-size: 100% auto; 28 | height: 200px; 29 | margin: -48px -24px -24px -24px; 30 | } 31 | 32 | .profile-card { 33 | background: white; 34 | padding: 24px; 35 | margin: 0 -24px 0 -24px; 36 | border-radius: 24px 24px 0 0; 37 | } 38 | 39 | .profile-card .avatar { 40 | margin-top: -72px; /* 24+(96÷2) */ 41 | } 42 | 43 | .profile-card .avatar img { 44 | border: 2px solid white; 45 | } 46 | -------------------------------------------------------------------------------- /app/web/controllers/web/profiles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles profile views 6 | class ProfilesController < WebController 7 | include PolicyHelpers 8 | include LoadHelpers 9 | 10 | # TODO: /@handle should redirect to /@handle@example.org when we are 11 | # on example.org 12 | # TODO: /handle should redirect to /@handle@example.org as well 13 | get '/m/:handle' do 14 | raise NotFound, 'Member with that handle not found' if profile.null? 15 | 16 | erb(:profile, layout: :layout_member, locals: { profile: profile }) 17 | end 18 | 19 | private 20 | 21 | def profile 22 | @profile ||= decorate( 23 | load( 24 | Aggregates::Member, 25 | Projections::Members::Query.aggregate_id_for(params[:handle]) 26 | ), 27 | ViewModels::Profile, 28 | ViewModels::Profile::NullProfile 29 | ) 30 | end 31 | alias contact profile 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/commands/profile/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/registration' 4 | 5 | module Commands 6 | module Profile 7 | module Update 8 | ## 9 | # Command to invite a new +Member+. 10 | class Command < ApplicationCommand 11 | end 12 | 13 | # Handler for Profile::Update::Command 14 | class CommandHandler < ApplicationCommandHandler 15 | ## 16 | # Overridden from ApplicationCommandHandler because we want 17 | # to call multiple methods on the aggregate root, based 18 | # on conditions 19 | def handle 20 | command.validate 21 | 22 | applied_aggregate = aggregate.update_bio(payload).update_name(payload) 23 | 24 | repository.save(applied_aggregate) 25 | applied_aggregate 26 | end 27 | 28 | def aggregate_class 29 | Aggregates::Member 30 | end 31 | 32 | def payload 33 | command.payload 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/commands/application_command_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Commands 4 | ## 5 | # Main ApplicationCommandHandler; runs the command, to be inherited from 6 | # and extended when commands needs specific handling. 7 | # 8 | # For now, it always applies the command to an aggregate. But we might want 9 | # to split it into a AggregateApplyCommandHandler instead. 10 | class ApplicationCommandHandler 11 | def initialize(command:, repository: Roost.repository) 12 | @repository = repository 13 | @command = command 14 | @aggregate = nil 15 | end 16 | 17 | def handle 18 | command.validate 19 | 20 | applied_aggregate = aggregate.public_send( 21 | aggregate_method, 22 | command.payload 23 | ) 24 | 25 | repository.save(applied_aggregate) 26 | applied_aggregate 27 | end 28 | 29 | protected 30 | 31 | attr_reader :repository, :command 32 | 33 | def aggregate 34 | repository.load(aggregate_class, command.aggregate_id) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/web/controllers/web/my_profiles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles my profile views: member interacts with own profile 6 | class MyProfilesController < WebController 7 | get '/profile' do 8 | requires_authorization 9 | profile = ViewModels::Profile.new(current_member) 10 | erb :profile, layout: :layout_member, locals: { profile: profile } 11 | end 12 | 13 | put '/profile' do 14 | requires_authorization 15 | Commands.handle('Profile', 'Update', 16 | put_params.merge(aggregate_id: member_id)) 17 | redirect '/profile' 18 | end 19 | 20 | get '/profile/edit' do 21 | requires_authorization 22 | profile = ViewModels::Profile.new(current_member) 23 | erb(:profile_edit, layout: :layout_member, locals: { profile: profile }) 24 | end 25 | 26 | private 27 | 28 | def put_params 29 | params.slice('bio', 'name') 30 | end 31 | 32 | def may_add_contact? 33 | false # We may never add ourselves. 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/web/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sinatra' 4 | require 'sinatra/reloader' if Roost.development? 5 | 6 | ## 7 | # Main fallback controller for all http requests 8 | class ApplicationController < Sinatra::Base 9 | # Ensure our error handlers are triggered in development 10 | set :show_exceptions, :after_handler if Roost.development? 11 | 12 | set :views, Roost.root.join('app/web/views') 13 | set :public_folder, Roost.root.join('app/web/public') 14 | 15 | configure :development do 16 | # :nocov: 17 | # This is only enabled in development env, and not test. 18 | register Sinatra::Reloader 19 | # :nocov: 20 | end 21 | 22 | protected 23 | 24 | def requires_authorization 25 | authorize { current_member.active? } 26 | end 27 | 28 | def authorize(&block) 29 | raise Unauthorized unless block.call 30 | end 31 | 32 | def current_member 33 | return OpenStruct.new(active?: false) unless member_id 34 | 35 | @current_member ||= Roost.repository.load(Aggregates::Member, member_id) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/integration/cli/sink_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require 'bcrypt' 6 | require 'open3' 7 | 8 | ## 9 | # As a owner of an instance 10 | # When I bootstrap my instance 11 | # Then I want to import exiting data 12 | # So that I have data in my instance 13 | class SinkTest < Minitest::Spec 14 | let(:input_file) { fixtures('input/harry_potter.json') } 15 | 16 | it 'creates an event' do 17 | run_sink_pipe 18 | assert_kind_of(MemberAdded, last_event) 19 | refute_nil(last_event.aggregate_id) 20 | assert_equal(last_event.body['name'], 'Harry Potter') 21 | end 22 | 23 | it 'hashes password' do 24 | run_sink_pipe 25 | assert(BCrypt::Password.new(last_event.body['password']), 'caput draconis') 26 | end 27 | 28 | it 'updates duplicates and continues' do 29 | run_sink_pipe 30 | run_sink_pipe 31 | end 32 | 33 | def run_sink_pipe 34 | status_list = Open3.pipeline( 35 | ['cat', input_file], 36 | [{ 'LOG_LEVEL' => '1' }, './bin/sink'] 37 | ) 38 | status_list.each { |status| assert status.success? } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/aggregates/member/tag_list_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'app/aggregates/member/tag_list.rb' 5 | 6 | module Aggregates 7 | class Member 8 | ## 9 | # Test TagList model; the collection of tags on a member 10 | class TagListTest < Minitest::Spec 11 | let(:friend_by_harry) { Minitest::Mock.new } 12 | let(:friend_by_ron) { Minitest::Mock.new } 13 | let(:subject) { TagList.new } 14 | 15 | it 'adds tags through <<' do 16 | subject << friend_by_harry 17 | assert_includes(subject, friend_by_harry) 18 | end 19 | 20 | it 'merges tags who are eql when adding' do 21 | friend_by_harry.expect(:==, true, [friend_by_ron]) 22 | friend_by_harry.expect(:==, true, [friend_by_ron]) 23 | 24 | friend_by_harry.expect(:merge, friend_by_harry, [friend_by_ron]) 25 | 26 | subject << friend_by_harry 27 | subject << friend_by_ron 28 | assert_equal(1, subject.length) 29 | friend_by_harry.verify 30 | end 31 | 32 | # TODO: implement limits. 33 | # TODO: implement ordering. 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice (including the next 13 | paragraph) shall be included in all copies or substantial portions of the 14 | Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 19 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 21 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/lib/mail_renderer_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class MailRendererTest < Minitest::Spec 6 | let(:templates_root) { Roost.root.join('tmp').to_s } 7 | subject { MailRenderer.new(templates_root: templates_root) } 8 | 9 | before :each do 10 | # ensure we have a template file 11 | File.write(File.join(templates_root, "#{template}.erb"), template_body) 12 | end 13 | 14 | after :each do 15 | # Clean up the template file 16 | File.unlink(File.join(templates_root, "#{template}.erb")) 17 | end 18 | 19 | describe '#render' do 20 | let(:template) { :test_mail_body } 21 | let(:template_body) { 'Hello <%= planet %>' } 22 | 23 | it 'renders a template with ERB' do 24 | assert_equal( 25 | subject.render(template, { planet: 'world' }), 26 | 'Hello world' 27 | ) 28 | end 29 | 30 | it 'raises a NameError for unkown locals' do 31 | assert_raises(NameError) { subject.render(template, {}) } 32 | end 33 | 34 | it 'raises an Errno::ENOENT for a missing template' do 35 | assert_raises(Errno::ENOENT) { subject.render(:missing) } 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/web/views/confirmation_success.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Flockingbird - Confirm 7 | 8 | 14 | 25 | 26 | 27 |
28 |
29 |
30 | 31 | Email address confirmed. 32 | Welcome! 33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /test/lib/handle_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # Test the Handle library 7 | class HandleTest < Minitest::Spec 8 | it 'parses @ron@example.org handles and strips the @' do 9 | assert_equal(Handle.parse('@ron@example.org').username, 'ron') 10 | end 11 | 12 | it 'parses ron@example.org handles' do 13 | assert_equal(Handle.parse('@ron@example.org').username, 'ron') 14 | end 15 | 16 | it 'parses ron@any.example.org handles' do 17 | assert_equal(Handle.parse('@ron@any.example.org').domain, 'any.example.org') 18 | end 19 | 20 | it 'builds a handle as string from a username using local web_url' do 21 | assert_equal(Handle.new('harry').to_s, '@harry@example.com') 22 | end 23 | 24 | it 'is equal when both url and username are equal' do 25 | assert_equal(Handle.new('harry'), Handle.new('harry')) 26 | assert_equal( 27 | Handle.new('harry', 'example.com'), 28 | Handle.new('harry', 'example.com') 29 | ) 30 | 31 | refute_equal(Handle.new('harry'), Handle.new('ron')) 32 | refute_equal( 33 | Handle.new('harry', 'example.org'), 34 | Handle.new('harry', 'example.com') 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/time_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TimeHelpers 4 | ## 5 | # Time Helper to freeze the time at die_wende for the duration of a block 6 | # stub goes away once the block is done 7 | def at_time(time = die_wende, &block) 8 | Time.stub :now, time do 9 | Date.stub :today, time.to_date do 10 | yield block 11 | end 12 | end 13 | end 14 | 15 | ## 16 | # Time Helper, returns a Time 17 | # Time is the moment the Berlin Wall fell: 9-11-1989 18:57:00 18 | # 19 | # November 1989 20 | # zo ma di wo do vr za 21 | # 1 2 3 4 22 | # > 5 6 7 8 9 10 11 23 | # 12 13 14 15 16 17 18 24 | # 19 20 21 22 23 24 25 25 | # 26 27 28 29 30 26 | def die_wende 27 | Time.local(1989, 11, 9, 18, 57, 0, 0) 28 | end 29 | 30 | ## 31 | # Assert after a certain time 32 | def assert_time_after(expected, actual = Time.now.getlocal) 33 | assert expected < actual, "#{actual} is not after #{expected}" 34 | end 35 | 36 | ## 37 | # Assert before a certain time 38 | def assert_time_before(expected, actual = Time.now.getlocal) 39 | assert expected > actual, "#{actual} is not before #{expected}" 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/web/controllers/web/contacts_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles contacts index and addition 6 | class ContactsController < WebController 7 | include PolicyHelpers 8 | 9 | # Index 10 | get '/contacts' do 11 | contacts = ViewModels::Profile.from_collection( 12 | Projections::Contacts::Query.for_member(member_id) 13 | ) 14 | erb :contacts, layout: :layout_member, locals: { contacts: contacts } 15 | end 16 | 17 | # Add 18 | post '/contacts' do 19 | requires_authorization 20 | authorize { may_add_contact? } 21 | 22 | Commands.handle( 23 | 'Contact', 'Add', 24 | { 'handle' => contact.handle, 'owner_id' => current_member.member_id } 25 | ) 26 | 27 | flash[:success] = "#{contact.handle} was added to your contacts" 28 | redirect "/m/#{contact.handle}" 29 | end 30 | 31 | private 32 | 33 | def contact 34 | aggregate_id = Projections::Members::Query.aggregate_id_for(handle) 35 | Roost.repository.load(Aggregates::Member, aggregate_id) 36 | end 37 | 38 | def handle 39 | Handle.parse(params['handle']) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/workflows.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'workflows/base' 4 | 5 | Dir["#{__dir__}/workflows/*.rb"].sort.each { |file| require file } 6 | 7 | ## 8 | # Workflows are classes that walk us through steps using the official interface. 9 | module Workflows 10 | def adds_contact(form_attributes = {}) 11 | factory(AddsContact, form_attributes) 12 | end 13 | 14 | def manage_profile(form_attributes = {}) 15 | factory(ManageProfile, form_attributes) 16 | end 17 | 18 | def member_logs_in(form_attributes = {}) 19 | factory(MemberLogsIn, form_attributes) 20 | end 21 | 22 | def member_registers(form_attributes = {}) 23 | factory(MemberRegisters, form_attributes) 24 | end 25 | 26 | def tags_member(form_attributes = {}) 27 | factory(TagsMember, form_attributes) 28 | end 29 | 30 | def discover_member(form_attributes = {}) 31 | factory(DiscoversMember, form_attributes) 32 | end 33 | 34 | # login helper 35 | def as(login_attributes) 36 | member_logs_in(login_attributes).upto(:logged_in) 37 | yield if block_given? 38 | end 39 | 40 | private 41 | 42 | def factory(klass, form_attributes) 43 | klass.new(self, form_attributes) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/web/view_models/profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'lib/aggregate_equality' 4 | 5 | module ViewModels 6 | ## 7 | # A Member Profile view model 8 | class Profile < SimpleDelegator 9 | include AggregateEquality 10 | 11 | def self.from_collection(collection) 12 | collection.map { |obj| build(obj) } 13 | end 14 | 15 | def self.build(obj = nil) 16 | case obj 17 | when NilClass 18 | NullProfile.new if obj.nil? 19 | when Hash 20 | new(OpenStruct.new(obj)) 21 | else 22 | new(obj) 23 | end 24 | end 25 | 26 | def updated_on 27 | updated_at.to_date 28 | end 29 | 30 | def updated_at 31 | super || NullDateTime.new('never') 32 | end 33 | 34 | def null? 35 | false 36 | end 37 | 38 | ## 39 | # Standin for empty profile 40 | class NullProfile < NullObject 41 | def name 42 | placeholder 43 | end 44 | 45 | def handle 46 | Handle.new(placeholder) 47 | end 48 | 49 | def updated_on 50 | updated_at.to_date 51 | end 52 | 53 | def updated_at 54 | NullDateTime.new('never') 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/reactors/invitation_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Reactors 4 | ## 5 | # Sends the invitation to a member 6 | class InvitationMailer 7 | include EventSourcery::Postgres::Reactor 8 | 9 | processor_name :invitation_mailer 10 | 11 | # emits_events ExampleEvent, AnotherEvent 12 | 13 | # table :reactor_invitation_mailer do 14 | # column :todo_id, :uuid, primary_key: true 15 | # column :title, :text 16 | # end 17 | 18 | process MemberInvited do |event| 19 | request_attributes = event.body['data']['attributes'] 20 | inviter = event.body['inviter'] 21 | 22 | # TODO: implement a failure handling catching errors from Pony. 23 | email = Mail.new( 24 | to: request_attributes['to_email'], 25 | from: from(inviter).format, 26 | subject: subject(inviter) 27 | ) 28 | email.deliver 29 | end 30 | 31 | private 32 | 33 | def from(inviter) 34 | from = Mail::Address.new 35 | from.address = inviter['email'] 36 | from.display_name = inviter['name'] 37 | from 38 | end 39 | 40 | def subject(inviter) 41 | "#{inviter['name']} invited you to join Flockingbird" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/commands/contact/add_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'bcrypt' 5 | 6 | ## 7 | # Test the Commands::Contact::Add::Command 8 | class ContactAddCommandTest < Minitest::Spec 9 | let(:subject_class) { Commands::Contact::Add::Command } 10 | subject { subject_class.new(params) } 11 | let(:params) do 12 | { 13 | 'handle' => '@harry@example.com', 14 | 'owner_id' => fake_uuid(Aggregates::Contact, 1) 15 | } 16 | end 17 | 18 | describe 'with owner_id and handle' do 19 | let(:uuid_v5_for_contact) { 'aea867c4-c623-5f5c-b6df-f25423d10ab9' } 20 | 21 | it 'generates a UUIDv5 for this combination' do 22 | assert_equal(uuid_v5_for_contact, subject.aggregate_id) 23 | end 24 | end 25 | 26 | describe 'validate' do 27 | it 'raises BadRequest when handle is empty' do 28 | assert_raises(BadRequest, 'contact handle is blank') do 29 | subject_class.new(params.merge('handle' => '')).validate 30 | end 31 | end 32 | 33 | it 'raises BadRequest when email is empty' do 34 | assert_raises(BadRequest, 'owner_id is blank') do 35 | subject_class.new(params.merge('owner_id' => '')).validate 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'event_sourcery', git: 'https://github.com/envato/event_sourcery.git' 6 | gem 'event_sourcery-postgres', git: 'https://github.com/envato/event_sourcery-postgres.git' 7 | 8 | gem 'bcrypt' 9 | gem 'dotenv', '~> 2.7' 10 | gem 'mail' 11 | gem 'rack-jwt', git: 'https://github.com/uplisting/rack-jwt.git', 12 | branch: 'allow-excluding-root-path' 13 | gem 'rake' 14 | gem 'sinatra' 15 | gem 'sinatra-contrib' 16 | gem 'sinatra-flash' 17 | # NOTE: pg is an implicit dependency of event_sourcery-postgres but we need to 18 | # lock to an older version for deprecation warnings. 19 | gem 'pg', '0.20.0' 20 | gem 'uuidtools' 21 | gem 'yajl-ruby' 22 | 23 | group :development, :test do 24 | gem 'awesome_print' 25 | gem 'better_errors' 26 | gem 'byebug' 27 | gem 'capybara' 28 | gem 'database_cleaner-sequel' 29 | gem 'event_sourcery_generators', git: 'https://github.com/envato/event_sourcery_generators.git' 30 | gem 'launchy' 31 | gem 'minitest' 32 | gem 'pry' 33 | gem 'rack-test' 34 | gem 'rspec' 35 | gem 'rubocop', '~> 0.82.0' # Matches superlinter version 36 | 37 | # Bug in later versions: https://github.com/codeclimate/test-reporter/issues/413 38 | gem 'simplecov', '~> 0.17.0' 39 | end 40 | -------------------------------------------------------------------------------- /app/reactors/confirmation_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Reactors 4 | ## 5 | # Sends the confirmation mail to a new registrant 6 | class ConfirmationMailer 7 | include EventSourcery::Postgres::Reactor 8 | 9 | processor_name :confirmation_mailer 10 | 11 | emits_events ConfirmationEmailSent 12 | 13 | process RegistrationRequested do |event| 14 | address = event.body['email'] 15 | aggregate_id = event.aggregate_id 16 | 17 | # TODO: implement a failure handling catching errors from Pony. 18 | email_attrs = { 19 | to: address, 20 | from: 'Bèr at Flockingbird ', 21 | subject: 'Welcome to Flockingbird. Please confirm your email address', 22 | body: MailRenderer.new.render( 23 | :registration_mail, 24 | confirmation_url: confirmation_url(aggregate_id) 25 | ) 26 | } 27 | Mail.new(email_attrs).deliver 28 | 29 | emit_event( 30 | ConfirmationEmailSent.new( 31 | aggregate_id: aggregate_id, 32 | body: { email_attrs: email_attrs } 33 | ) 34 | ) 35 | end 36 | 37 | private 38 | 39 | def confirmation_url(aggregate_id) 40 | "#{Roost.config.web_url}/confirmation/#{aggregate_id}" 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/support/event_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Helpers for testing events. 5 | module EventHelpers 6 | def assert_aggregate_has_event(klass) 7 | assert_includes(subject.changes.map(&:class), klass) 8 | end 9 | 10 | def refute_aggregate_has_event(klass) 11 | refute_includes(subject.changes.map(&:class), klass) 12 | end 13 | 14 | def last_event(aggregate_id = nil) 15 | unless aggregate_id 16 | event_id = event_store.latest_event_id 17 | aggregate_id = event_store.get_next_from(event_id).last.aggregate_id 18 | end 19 | 20 | event_store.get_events_for_aggregate_id(aggregate_id).last 21 | end 22 | 23 | def setup_processors 24 | processors.each(&:setup) 25 | end 26 | 27 | def process_events(event_types) 28 | processors.map do |processor| 29 | events = event_store.get_next_from( 30 | (processor.last_processed_event_id + 1), 31 | event_types: event_types 32 | ) 33 | 34 | events.each do |ev| 35 | processor.process(ev) 36 | Roost.tracker.processed_event(processor.processor_name, ev.id) 37 | end 38 | end 39 | end 40 | 41 | protected 42 | 43 | def event_store 44 | Roost.event_store 45 | end 46 | 47 | def processors 48 | @processors ||= Roost.all_processors.map(&:new) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/aggregates/member/tag_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'app/aggregates/member/tag.rb' 5 | 6 | module Aggregates 7 | class Member 8 | ## 9 | # Test Tag module under Member Aggregate 10 | class TagTest < Minitest::Spec 11 | let(:harry_author) { fake_uuid(Aggregates::Member, 1) } 12 | let(:ron_author) { fake_uuid(Aggregates::Member, 2) } 13 | let(:subject) { Tag.new('friend', harry_author) } 14 | 15 | it 'makes authors from passed in author' do 16 | assert_equal([harry_author], subject.authors) 17 | end 18 | 19 | it 'is equal when names are equal' do 20 | assert(subject == Tag.new('friend', harry_author)) 21 | end 22 | 23 | it 'is equal when names are equal but authors are not' do 24 | assert(subject == Tag.new('friend', ron_author)) 25 | end 26 | 27 | it 'is comparable to nil' do 28 | refute_equal(nil, subject) 29 | end 30 | 31 | it 'appends authors on merge' do 32 | assert_equal( 33 | [harry_author, ron_author], 34 | subject.merge(Tag.new('friend', ron_author)).authors 35 | ) 36 | end 37 | 38 | it 'by? reports true when author is amoungs authors' do 39 | assert(subject.by?(harry_author)) 40 | refute(subject.by?(ron_author)) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/support/shared/attribute_behaviour.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Shared behaviour for attribute-setters on an Aggregate 5 | module AttributeBehaviour 6 | def self.included(base) 7 | base.extend(ClassMethods) 8 | end 9 | 10 | ## 11 | # Class-level it_* methods. 12 | module ClassMethods 13 | def it_behaves_as_attribute_setter(method, attribute, event) 14 | it_sets_attribute(method, attribute) 15 | it_sends_event_on_change(method, attribute, event) 16 | it_omits_event_on_no_change(method, attribute, event) 17 | end 18 | 19 | def it_sets_attribute(method, attribute) 20 | it "#{method} sets #{attribute}" do 21 | subject.send(method, attribute => 'to value') 22 | assert_equal(subject.send(attribute), 'to value') 23 | end 24 | end 25 | 26 | def it_sends_event_on_change(method, attribute, event) 27 | it "#{method} emits a #{event} when the bio changed" do 28 | subject.send(method, attribute => 'to value') 29 | assert_aggregate_has_event(event) 30 | end 31 | end 32 | 33 | def it_omits_event_on_no_change(method, attribute, event) 34 | it "#{method} does not emit a #{event} when the bio will not change" do 35 | subject.send(method, attribute => subject.send(attribute)) 36 | refute_aggregate_has_event(event) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/web/controllers/web/tags_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | ## 5 | # Handles viewing of tags-edit page and adding new tags to a member 6 | class TagsController < WebController 7 | include LoadHelpers 8 | 9 | # TODO: DRY with ProfilesController 10 | get '/m/:handle/tags' do 11 | raise NotFound, 'Member with that handle not found' if profile.null? 12 | 13 | erb(:profile_tags, layout: :layout_member, locals: { profile: profile }) 14 | end 15 | 16 | post '/tags' do 17 | requires_authorization 18 | 19 | Commands.handle( 20 | 'Profile', 21 | 'Tag', 22 | post_params.merge( 23 | aggregate_id: profile.id, 24 | author_id: member_id 25 | ) 26 | ) 27 | 28 | # TODO: raise if no profile handle given 29 | flash[:success] = "#{profile.handle} was tagged as "\ 30 | "\"#{post_params['tag']}\"" 31 | redirect "/m/#{profile.handle}" 32 | end 33 | 34 | private 35 | 36 | def profile 37 | @profile ||= decorate( 38 | load( 39 | Aggregates::Member, 40 | Projections::Members::Query.aggregate_id_for(params[:handle]) 41 | ), 42 | ViewModels::Profile, 43 | ViewModels::Profile::NullProfile 44 | ) 45 | end 46 | 47 | def post_params 48 | params.slice('tag') 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/workflows/member_registers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Workflows 4 | ## 5 | # Workflow to register as a new member 6 | class MemberRegisters < Base 7 | def registered 8 | visit '/' 9 | click_link 'Register' 10 | 11 | fill_in('Username', with: form_attributes[:username]) 12 | fill_in('Password', with: form_attributes[:password]) 13 | fill_in('Email', with: form_attributes[:email]) 14 | click_button('Register') 15 | 16 | process_events(%w[registration_requested]) 17 | page 18 | end 19 | 20 | def confirmed 21 | visit confirmation_path_from_mail 22 | process_events(%w[registration_confirmed member_added]) 23 | page 24 | end 25 | 26 | def form_attributes 27 | { 28 | username: 'hpotter', 29 | password: 'caput draconis', 30 | email: 'harry@hogwards.edu.wiz' 31 | }.merge(@form_attributes) 32 | end 33 | 34 | private 35 | 36 | def confirmation_path_from_mail 37 | mail = Mail::TestMailer.deliveries.reverse.find do |email| 38 | email.subject.match?(/Please confirm your email address/) 39 | end 40 | 41 | refute_nil(mail, 'No confirmation mail found') 42 | base_url = Roost.config.web_url 43 | path = mail.body.match(%r{#{base_url}(/confirmation/.*)}) 44 | path[1] 45 | end 46 | 47 | def steps 48 | %i[registered confirmed].freeze 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/projections/contacts/projector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../members/query' 4 | 5 | module Projections 6 | module Contacts 7 | ## 8 | # Stores Contacts as a hash map so we can join the actual members 9 | class Projector 10 | include EventSourcery::Postgres::Projector 11 | 12 | projector_name :contacts 13 | 14 | table :contacts do 15 | column :owner_id, 'UUID NOT NULL' 16 | column :contact_id, 'UUID NOT NULL' 17 | column :handle, :text 18 | column :name, :text 19 | column :bio, :text 20 | column :updated_at, DateTime 21 | end 22 | 23 | project ContactAdded do |event| 24 | aggregate_id = Members::Query.aggregate_id_for(event.body['handle']) 25 | contact = Roost.repository.load(Aggregates::Member, aggregate_id) 26 | 27 | table.insert( 28 | owner_id: event.body['owner_id'], 29 | contact_id: aggregate_id, 30 | handle: contact.handle.to_s, 31 | name: contact.name, 32 | bio: contact.bio, 33 | updated_at: Time.now 34 | ) 35 | end 36 | 37 | project MemberBioUpdated do |event| 38 | table.where(contact_id: event.aggregate_id) 39 | .update(bio: event.body['bio']) 40 | end 41 | 42 | project MemberNameUpdated do |event| 43 | table.where(contact_id: event.aggregate_id) 44 | .update(name: event.body['name']) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/commands/contact/add.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/contact' 4 | 5 | module Commands 6 | module Contact 7 | module Add 8 | ## 9 | # Command to add a new +Member+ 10 | class Command < ApplicationCommand 11 | UUID_CONTACT_NAMESPACE = UUIDTools::UUID.parse( 12 | '24b3a7e6-7e74-4651-92cf-003a12087488' 13 | ) 14 | REQUIRED_PARAMS = %w[handle owner_id].freeze 15 | ALLOWED_PARAMS = REQUIRED_PARAMS 16 | 17 | def initialize(params) 18 | @payload = params.slice(*ALLOWED_PARAMS) 19 | @aggregate_id = aggregate_id 20 | end 21 | 22 | def aggregate_id 23 | @aggregate_id ||= uuid_v5 24 | end 25 | 26 | def validate 27 | REQUIRED_PARAMS.each do |param| 28 | if (payload[param] || '').to_s.empty? 29 | raise BadRequest, "#{param} is blank" 30 | end 31 | end 32 | end 33 | 34 | private 35 | 36 | def aggregate_id_name 37 | "#{payload['handle']}#{payload['owner_id']}" 38 | end 39 | 40 | def aggregate_id_namespace 41 | UUID_CONTACT_NAMESPACE 42 | end 43 | end 44 | 45 | # Handler for AddMember::Command 46 | class CommandHandler < ApplicationCommandHandler 47 | def aggregate_class 48 | Aggregates::Contact 49 | end 50 | 51 | def aggregate_method 52 | :add 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/support/data_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Helpers for test-data 5 | module DataHelpers 6 | KLASS_N = { 7 | 'Aggregates::Member' => 1, 8 | 'Aggregates::Registration' => 2, 9 | 'Aggregates::Session' => 3, 10 | 'Aggregates::Contact' => 4 11 | }.freeze 12 | 13 | protected 14 | 15 | ## 16 | # Generate a deterministic fake UUID for an aggregate. 17 | # +klass+ the Aggregate to generate the UUID for. Makes it easy to recognise 18 | # +sequence+ sequence number to guarantee uniqueness. 19 | def fake_uuid(klass, sequence) 20 | format( 21 | '%08x-0000-4000-8000-%012x', 22 | klass_n: KLASS_N.fetch(klass.to_s, 0), 23 | sequence: sequence 24 | ) 25 | end 26 | 27 | def fixtures(file) 28 | Roost.root.join('test', 'fixtures', file).to_s 29 | end 30 | 31 | def json_fixtures(file) 32 | JSON.parse(File.read(fixtures(file)), symbolize_names: true) 33 | end 34 | 35 | def harry 36 | registers = member_registers 37 | registers.upto(:confirmed) 38 | 39 | member_registers.form_attributes 40 | end 41 | 42 | def ron 43 | ron = { username: 'ron', email: 'ron@example.org', password: 'secret' } 44 | member_registers(ron).upto(:confirmed).html 45 | 46 | ron 47 | end 48 | 49 | def hermoine 50 | hermoine = { 51 | username: 'hermoine', 52 | email: 'hermoine@example.org', 53 | password: 'secret' 54 | } 55 | member_registers(hermoine).upto(:confirmed).html 56 | 57 | hermoine 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/integration/web/member_logs_in_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a member of flockingbird 7 | # When my session was ended 8 | # Then I want to log in with my credentials 9 | # So that I can use the app as a member 10 | class MemberLogsInTest < Minitest::WebSpec 11 | describe 'registered user' do 12 | before do 13 | @workflow = member_registers 14 | @workflow.upto(:confirmed) 15 | # TODO: once confirmed, aren't we logged in already? 16 | 17 | visit '/' 18 | click_link 'Login' 19 | end 20 | 21 | it 'logs in using credentials set at test' do 22 | fill_in('Username', with: @workflow.form_attributes[:username]) 23 | fill_in('Password', with: @workflow.form_attributes[:password]) 24 | click_button('Login') 25 | 26 | assert_content( 27 | find('h2.title'), 28 | 'Your contacts' 29 | ) 30 | end 31 | 32 | it 'attempts to login using wrong password' do 33 | fill_in('Username', with: @workflow.form_attributes[:username]) 34 | fill_in('Password', with: 'pure-blood') 35 | click_button('Login') 36 | 37 | assert_content( 38 | find('.notification.is-error'), 39 | 'Could not log in. Is the username and password correct?' 40 | ) 41 | end 42 | 43 | it 'attempts to login using wrong username' do 44 | # leave the form empty 45 | click_button('Login') 46 | 47 | assert_content( 48 | find('.notification.is-error'), 49 | 'Could not log in. Is the username and password correct?' 50 | ) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /.github/workflows/superlinter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ########################### 3 | ########################### 4 | ## Linter GitHub Actions ## 5 | ########################### 6 | ########################### 7 | name: Lint Code Base 8 | 9 | # 10 | # Documentation: 11 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 12 | # 13 | 14 | ############################# 15 | # Start the job on all push # 16 | ############################# 17 | on: 18 | push: 19 | branches: 20 | - main 21 | - develop 22 | - 'release/**' 23 | - 'hotfix/**' 24 | - 'feature/**' 25 | pull_request: 26 | 27 | ############### 28 | # Set the Job # 29 | ############### 30 | jobs: 31 | build: 32 | # Name the Job 33 | name: Lint Code Base 34 | # Set the agent to run on 35 | runs-on: ubuntu-latest 36 | 37 | ################## 38 | # Load all steps # 39 | ################## 40 | steps: 41 | ########################## 42 | # Checkout the code base # 43 | ########################## 44 | - name: Checkout Code 45 | uses: actions/checkout@v2 46 | with: 47 | # Full git history is needed to get a proper list of changed files within `super-linter` 48 | fetch-depth: 0 49 | 50 | ################################ 51 | # Run Linter against code base # 52 | ################################ 53 | - name: Lint Code Base 54 | uses: github/super-linter@v3 55 | env: 56 | VALIDATE_ALL_CODEBASE: false 57 | DEFAULT_BRANCH: main 58 | FILTER_REGEX_EXCLUDE: app/web/public/css/vendor/.* 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | -------------------------------------------------------------------------------- /app/web/views/profile.erb: -------------------------------------------------------------------------------- 1 | <%- content_for(:title, "My profile") %> 2 |
3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |
11 |

<%= profile.name %>


12 | <%= profile.handle %> 13 |
14 |

15 | <%- if may_add_contact? %> 16 |

17 | 18 | 23 |
24 | <%- end %> 25 | 26 | 27 | 28 | 29 | 30 |

31 |
32 |

<%= profile.bio %>

33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | <%- profile.tags_for(current_member).each do |tag| %> 42 | 44 | <%= tag.name %> 45 | 46 | <%- end %> 47 |
48 |
49 | -------------------------------------------------------------------------------- /test/aggregates/registration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module Aggregates 6 | ## 7 | # Unit test for the more complex logic in Registration Aggregate 8 | class RegistrationTest < Minitest::Spec 9 | let(:id) { fake_uuid(Aggregates::Registration, 1) } 10 | 11 | subject do 12 | Aggregates::Registration.new(id, []) 13 | end 14 | 15 | let(:payload) do 16 | { 17 | username: 'hpotter', 18 | password: 'caput draconis', 19 | email: 'hpotter@hogwards.edu.wiz' 20 | } 21 | end 22 | 23 | describe 'request' do 24 | before { subject.request(payload) } 25 | 26 | it 'emits RegistrationRequested event' do 27 | assert_aggregate_has_event(RegistrationRequested) 28 | end 29 | 30 | it 'sets username' do 31 | assert_equal(subject.username, 'hpotter') 32 | end 33 | 34 | it 'sets email' do 35 | assert_equal(subject.email, 'hpotter@hogwards.edu.wiz') 36 | end 37 | 38 | it 'sets password' do 39 | assert_equal(subject.password, 'caput draconis') 40 | end 41 | end 42 | 43 | describe 'confirm' do 44 | before do 45 | subject.request(payload) 46 | subject.confirm({}) 47 | end 48 | 49 | it 'emits RegistrationConfirmed event' do 50 | assert_aggregate_has_event(RegistrationConfirmed) 51 | end 52 | 53 | it 'adds username email and password to event' do 54 | assert_equal( 55 | payload.transform_keys(&:to_s).merge('aggregate_id' => id), 56 | subject.changes.last.body 57 | ) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /app/web/views/layout_member.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= erb(:head) %> 5 | 6 | 7 |
8 |
9 | <%= notifications %> 10 | <%= yield %> 11 |
12 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/web/controllers/api/api_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack/jwt' 4 | 5 | module Api 6 | ## 7 | # The API server. Sinatra API only server. Main trigger for the commands 8 | # and entrypoint for reading data. 9 | class ApiController < ::ApplicationController 10 | # Find authentication details 11 | get '/api/session' do 12 | body current_member.to_h.slice(:aggregate_id, :handle, :name, :email) 13 | .to_json 14 | status(200) 15 | end 16 | 17 | # Create a new invitation 18 | post '/api/invitations/:aggregate_id' do 19 | invitation_params = json_params.merge(inviter: current_member.to_h) 20 | member = Commands.handle('Member', 'InviteMember', invitation_params) 21 | status(201) 22 | headers('Location' => invitation_url(member.invitation_token)) 23 | body '{}' 24 | end 25 | 26 | configure do 27 | # TODO: find a proper place to configure this 28 | jwt_args = { 29 | secret: ENV['JWT_SECRET'], 30 | verify: true, 31 | options: {} 32 | } 33 | use Rack::JWT::Auth, jwt_args 34 | end 35 | 36 | before do 37 | content_type :json 38 | end 39 | 40 | def json_params 41 | # Coerce this into a symbolised Hash so Sinatra data 42 | # structures don't leak into the command layer 43 | request_body = request.body.read 44 | params.merge!(JSON.parse(request_body)) unless request_body.empty? 45 | 46 | params.transform_keys(&:to_sym) 47 | end 48 | 49 | def invitation_url(id) 50 | URI.join(request.base_url, "/invitations/#{id}").to_s 51 | end 52 | 53 | def member_id 54 | request.env['jwt.payload']['sub'] 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/web/controllers/web/web_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sinatra/contrib' 4 | require 'sinatra/flash' 5 | 6 | module Web 7 | ## 8 | # Fallback controller for website. Serves HTML and digests FORM-encoded 9 | # requests 10 | class WebController < ::ApplicationController 11 | use Rack::MethodOverride 12 | register Sinatra::Flash 13 | helpers Sinatra::ContentFor 14 | 15 | enable :sessions 16 | 17 | error UnprocessableEntity do |error| 18 | body render_error(error.message) 19 | status 422 20 | end 21 | 22 | error BadRequest do |error| 23 | ## TODO: find out how to re-render a form with errors instead 24 | body render_error(error.message) 25 | status 400 26 | end 27 | 28 | error NotFound do |error| 29 | body render_error(error.message) 30 | status 404 31 | end 32 | 33 | error Unauthorized do |_error| 34 | # TODO: render the login form below 35 | body render_error( 36 | 'You are not logged in. Please log in or 37 | register to proceed' 38 | ) 39 | # NOTE: we don't send the 401, as that requires a WWW-Authenticate header, 40 | # that we cannot send in session/cookie based auth. 41 | status 403 42 | end 43 | 44 | protected 45 | 46 | def notifications 47 | flash(:flash).collect do |message| 48 | erb(:notification, locals: { message: message }) 49 | end.join 50 | end 51 | 52 | def render_error(message) 53 | content_for(:title, 'Error') 54 | erb(:error, locals: { message: message }, layout: :layout_anonymous) 55 | end 56 | 57 | def member_id 58 | session[:member_id] 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /bin/sink: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH << '.' 5 | 6 | require 'bundler/setup' 7 | require 'bcrypt' 8 | require 'logger' 9 | require 'securerandom' 10 | require 'yajl' 11 | 12 | require_relative '../config/environment' 13 | require_relative '../config/database' 14 | 15 | require_relative '../app/aggregates/member' 16 | require_relative '../app/commands/application_command' 17 | require_relative '../app/commands/application_command_handler' 18 | require_relative '../app/commands/member/add_member' 19 | 20 | LOG_LEVEL = ENV['LOG_LEVEL'].to_i || Logger::DEBUG 21 | 22 | # Handles a stream of nodes in STDIN emits them as events through the 23 | # AddMemberCommand. 24 | class EventSink 25 | include BCrypt 26 | 27 | def initialize 28 | @parser = Yajl::Parser.new(symbolize_keys: true) 29 | @parser.on_parse_complete = method(:object_parsed) 30 | 31 | @logger = Logger.new(STDOUT) 32 | EventSourcery.logger.level = @logger.level = LOG_LEVEL 33 | end 34 | 35 | def object_parsed(obj) 36 | log(Logger::DEBUG, '-- parsed object') 37 | 38 | obj[:aggregate_id] ||= SecureRandom.uuid 39 | 40 | obj[:password] = Password.create(obj[:password]) if obj.key?(:password) 41 | 42 | command = Commands::Member::AddMember::Command.new(obj) 43 | Commands::Member::AddMember::CommandHandler.new(command: command).handle 44 | rescue BadRequest => e 45 | log(Logger::ERROR, e.message) 46 | end 47 | 48 | def call(io) 49 | @parser.parse(io) 50 | rescue Yajl::ParseError => e 51 | log(Logger::ERROR, e.message) 52 | close_connection 53 | end 54 | 55 | private 56 | 57 | def log(severity, message = nil, progname = 'sink') 58 | @logger.add(severity, message, progname) 59 | end 60 | end 61 | 62 | EventSink.new.call(STDIN) 63 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CMD_PREFIX=bundle exec 2 | CONTAINER_NAME=roost_development 3 | TEST_FILES_PATTERN ?= **/*_test.rb 4 | 5 | # You want latexmk to *always* run, because make does not have all the info. 6 | # Also, include non-file targets in .PHONY so they are run regardless of any 7 | # file of the given name existing. 8 | .PHONY: all test lint clean setup ruby packages preprocess run 9 | 10 | # The first rule in a Makefile is the one executed by default ("make"). It 11 | # should always be the "all" rule, so that "make" and "make all" are identical. 12 | all: test lint 13 | db: 14 | make _docker-install || make _docker-start 15 | make _wait 16 | make _db-setup 17 | 18 | seed: 19 | cat test/fixtures/input/basic_users.jsonl | ./bin/sink 20 | 21 | # CUSTOM BUILD RULES 22 | test: export APP_ENV=test 23 | test: 24 | $(CMD_PREFIX) ruby -I lib:test:. -e "Dir.glob('$(TEST_FILES_PATTERN)') { |f| require(f) }" 25 | lint: 26 | $(CMD_PREFIX) rubocop 27 | 28 | clean: 29 | docker stop $(CONTAINER_NAME) 30 | docker rm $(CONTAINER_NAME) 31 | 32 | run: 33 | parallel --line-buffer ::: "$(CMD_PREFIX) rackup --port=9292 config.ru" "$(CMD_PREFIX) rake run_processors" 34 | 35 | deploy: 36 | $(CMD_PREFIX) cap production deploy 37 | 38 | _docker-start: 39 | @if [ -z $(docker ps --no-trunc | grep $(CONTAINER_NAME)) ]; then docker start $(CONTAINER_NAME); fi 40 | 41 | _db-setup: 42 | $(CMD_PREFIX) rake db:create 43 | $(CMD_PREFIX) rake db:migrate 44 | $(CMD_PREFIX) rake db:create_projections 45 | 46 | _wait: 47 | sleep 5 48 | 49 | ## 50 | # Set up the project for building 51 | setup: _ruby _packages _docker-install 52 | 53 | _docker-install: 54 | docker run -p 5432:5432 --name $(CONTAINER_NAME) -e POSTGRES_PASSWORD=$(DB_PASSWORD) -d mdillon/postgis 55 | 56 | _ruby: 57 | bundle install 58 | 59 | _packages: 60 | sudo apt install ruby parallel 61 | -------------------------------------------------------------------------------- /app/commands/registration/new_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/registration' 4 | require 'bcrypt' 5 | 6 | module Commands 7 | module Registration 8 | module NewRegistration 9 | ## 10 | # Command to invite a new +Member+. 11 | class Command < ApplicationCommand 12 | include BCrypt 13 | 14 | UUID_EMAIL_NAMESPACE = UUIDTools::UUID.parse( 15 | '2282b78c-85d6-419f-b240-0263d67ee6e6' 16 | ) 17 | 18 | REQUIRED_PARAMS = %w[email username password].freeze 19 | ALLOWED_PARAMS = REQUIRED_PARAMS 20 | 21 | # NewRegistration builds a UUIDv5 based on the mailaddress. 22 | def initialize(params) 23 | @payload = params.slice(*ALLOWED_PARAMS) 24 | 25 | # overwrite the password 26 | @payload['password'] = Password.create(@payload.delete('password')) 27 | 28 | @aggregate_id = aggregate_id 29 | end 30 | 31 | def aggregate_id 32 | @aggregate_id ||= uuid_v5 33 | end 34 | 35 | def validate 36 | REQUIRED_PARAMS.each do |param| 37 | if (payload[param] || '').empty? 38 | raise BadRequest, "#{param} is blank" 39 | end 40 | end 41 | end 42 | 43 | private 44 | 45 | def aggregate_id_name 46 | payload['email'] || '' 47 | end 48 | 49 | def aggregate_id_namespace 50 | UUID_EMAIL_NAMESPACE 51 | end 52 | end 53 | 54 | # Handler for NewRegistration::Command 55 | class CommandHandler < ApplicationCommandHandler 56 | def aggregate_class 57 | Aggregates::Registration 58 | end 59 | 60 | def aggregate_method 61 | :request 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/support/request_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | require 'json' 5 | require 'rack/jwt' 6 | 7 | module RequestHelpers 8 | include Rack::Test::Methods 9 | 10 | def app 11 | raise NotImplementedError 12 | end 13 | 14 | def id_from_header 15 | matches = last_response.headers['Location'].match(%r{.*/([^/]*)$}) 16 | matches[1] if matches 17 | end 18 | 19 | def put_json(url, body, env = {}) 20 | put(url, body.to_json, env) 21 | end 22 | 23 | def post_json(url, body = {}, env = {}) 24 | post(url, body.to_json, env) 25 | end 26 | 27 | def assert_status(status, message = nil) 28 | message ||= "Expected #{status}, got #{last_response.status}.\n"\ 29 | "#{last_response.body}" 30 | assert_equal(status, last_response.status, message) 31 | end 32 | 33 | def parsed_response 34 | JSON.parse(last_response.body, symbolize_names: true) 35 | end 36 | 37 | def authentication_payload 38 | return @authentication_payload if @authentication_payload 39 | 40 | now = Time.now.to_i 41 | 42 | @authentication_payload = { 43 | exp: now + 4 * 3600, 44 | nbf: now - 3600, 45 | iat: now, 46 | aud: 'audience', 47 | jti: jti_digest, 48 | # TODO: we'll need to add more than just a claim 'I am this person'; a 49 | # token or other authentication header 50 | sub: workflow.aggregate_id 51 | } 52 | end 53 | 54 | def jwt 55 | Rack::JWT::Token 56 | end 57 | 58 | def secret 59 | ENV['JWT_SECRET'] 60 | end 61 | 62 | def carry_cookie_from_cap 63 | cookie = page.driver.request.cookies['rack.session'] 64 | set_cookie("rack.session=#{cookie}") 65 | end 66 | 67 | private 68 | 69 | def jti_digest 70 | Digest::MD5.hexdigest([secret, Time.now.to_i].join(':').to_s) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/integration/web/contacts_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a member using the web-app 7 | # When I visit another members' profile 8 | # And I click the "add to contacts" button 9 | # Then the member is added to my contacts 10 | class MemberAddsToContactsTest < Minitest::WebSpec 11 | before do 12 | harry 13 | ron 14 | 15 | as(harry) 16 | discover_member(username: ron[:username]).upto(:profile_visited) 17 | adds_contact.upto(:contact_added) 18 | end 19 | 20 | it 'adds another member to contacts' do 21 | # NOTE that the handle uses .com and rons email .org 22 | assert_content( 23 | flash(:success), 24 | 'ron@example.com was added to your contacts' 25 | ) 26 | 27 | visit '/contacts' 28 | assert_content('ron@example.com') 29 | end 30 | 31 | it 'gets a notification when someone added me as a contact' do 32 | as(ron) do 33 | main_menu('Updates').click 34 | assert_content "hpotter@example.com #{Date.today}" 35 | # Until harry has changed their name, we render their handle 36 | assert_content 'hpotter@example.com added you to their contacts' 37 | end 38 | end 39 | 40 | it 'can add a contact only once' do 41 | # Ran in before, run again. 42 | discover_member(username: ron[:username]).upto(:profile_visited) 43 | adds_contact.upto(:contact_added) 44 | 45 | assert_content( 46 | flash(:error), 47 | 'Contact is already added' 48 | ) 49 | end 50 | 51 | it 'cannot add itself to contacts' do 52 | discover_member(username: harry[:username]).upto(:profile_visited) 53 | refute_selector(icon_selector('account-plus')) 54 | 55 | carry_cookie_from_cap 56 | # send the request manually. 1337h4xOr skills. 57 | post '/contacts', { handle: "@#{harry[:username]}@example.com" } 58 | assert_equal(403, last_response.status) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/web/helpers/load_helpers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require 'app/web/helpers/load_helpers' 6 | 7 | # Test Mock 8 | class TestFakeController 9 | include LoadHelpers 10 | end 11 | 12 | # Test Mock for a View Model 13 | class TestFakeViewModel 14 | def initialize(_obj); end 15 | end 16 | # Test Mock for a Null View Model 17 | class TestFakeViewNullModel; end 18 | 19 | ## 20 | # Test the Load Helpers Mixin 21 | class LoadHelpersTest < Minitest::Spec 22 | let(:aggregate_id) { fake_uuid(Aggregates::Member, 1) } 23 | let(:fake_respository) { Minitest::Mock.new } 24 | let(:subject) { TestFakeController.new } 25 | let(:aggregate) { OpenStruct.new } 26 | 27 | before do 28 | @repo = Roost.repository 29 | Roost.instance_variable_set(:@repository, fake_respository) 30 | 31 | fake_respository.expect( 32 | :load, 33 | aggregate, 34 | [Aggregates::Member, aggregate_id] 35 | ) 36 | end 37 | 38 | after do 39 | Roost.instance_variable_set(:@repository, @repo) 40 | end 41 | 42 | it '#load loads from the Aggregate repository' do 43 | subject.load(Aggregates::Member, aggregate_id) 44 | fake_respository.verify 45 | end 46 | 47 | it '#load does not load when aggregate_id is nil' do 48 | assert_nil(subject.load(Aggregates::Member, nil)) 49 | end 50 | 51 | it '#decorate decorates the object' do 52 | assert_kind_of( 53 | TestFakeViewModel, 54 | subject.decorate( 55 | Aggregates::Member.new(aggregate_id, []), 56 | TestFakeViewModel, 57 | TestFakeViewNullModel 58 | ) 59 | ) 60 | end 61 | 62 | it '#decorate decorates object with null view when object is null' do 63 | assert_kind_of( 64 | TestFakeViewNullModel, 65 | subject.decorate( 66 | nil, 67 | TestFakeViewModel, 68 | TestFakeViewNullModel 69 | ) 70 | ) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/integration/api/member_invites_member_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # A a client using the API on behalf of a member 7 | # When I have the rights to do so, 8 | # Then I can invite people by email, 9 | # So that I can help my network grow. 10 | class MemberInvitesMemberTest < Minitest::ApiSpec 11 | describe 'POST /invitations' do 12 | let(:invitee_email) { 'irene@example.com' } 13 | let(:invitee_name) { 'Irene' } 14 | let(:workflow) { Workflows::AddMember.new(self) } 15 | 16 | before do 17 | workflow.call 18 | header('Content-Type', 'application/vnd.api+json') 19 | header('Accept', 'application/vnd.api+json') 20 | 21 | bearer_token = jwt.encode(authentication_payload, secret, 'HS256') 22 | header('Authorization', "Bearer #{bearer_token}") 23 | 24 | assert_mail_deliveries(0) 25 | end 26 | 27 | it 'sends an invitation email' do 28 | post_json( 29 | "/api/invitations/#{fake_uuid(Aggregates::Member, 1)}", 30 | { 31 | data: { 32 | type: 'invitation', 33 | attributes: { 34 | to_email: invitee_email, 35 | to_name: invitee_name 36 | } 37 | } 38 | } 39 | ) 40 | assert_status(201) 41 | 42 | process_events(['member_invited']) 43 | 44 | assert_mail_deliveries(1) 45 | assert_includes(invitation_email.to, invitee_email) 46 | # Test against the raw header, because .from has the normalised version 47 | # and we want to check the full name 48 | assert_includes( 49 | invitation_email.header[:from].value, 50 | "#{workflow.member_name} <#{workflow.member_email}>" 51 | ) 52 | assert_match( 53 | /Harry Potter invited you to join Flockingbird/, 54 | invitation_email.subject 55 | ) 56 | end 57 | end 58 | 59 | private 60 | 61 | def invitation_email 62 | deliveries.last 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/commands/session/start_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class SessionStartCommandTest < Minitest::Spec 6 | let(:projection) { Minitest::Mock.new } 7 | let(:params) { { 'username' => 'hpotter', 'password' => 'caput draconis' } } 8 | let(:member) { nil } 9 | 10 | subject do 11 | Commands::Session::Start::Command.new(params, projection: projection) 12 | end 13 | 14 | before { projection.expect(:find_by, member, [{ username: 'hpotter' }]) } 15 | 16 | describe 'with username' do 17 | let(:uuid_v5_for_username) { '404fd9f9-c5fc-5b21-b2a9-9ad650520aff' } 18 | 19 | it 'generates a UUIDv5 for this username' do 20 | assert_equal(uuid_v5_for_username, subject.aggregate_id) 21 | end 22 | end 23 | 24 | describe 'without username' do 25 | before { params['username'] = '' } 26 | 27 | it 'handles empty username' do 28 | assert_equal(subject.aggregate_id, '') 29 | end 30 | end 31 | 32 | describe '#validate' do 33 | describe 'without member with username' do 34 | it 'fails if no member with this username is found' do 35 | assert_raises(BadRequest) { subject.validate } 36 | end 37 | end 38 | 39 | describe 'with member with username' do 40 | let(:member) { { password: BCrypt::Password.create('caput draconis') } } 41 | 42 | it 'passes if credentials match a record' do 43 | assert_nil(subject.validate) 44 | end 45 | 46 | it "fails if passwords don't match" do 47 | params['password'] = 'pure-blood' 48 | subject = Commands::Session::Start::Command.new( 49 | params, 50 | projection: projection 51 | ) 52 | assert_raises(BadRequest) { subject.validate } 53 | end 54 | end 55 | end 56 | 57 | describe 'payload' do 58 | let(:member) { { member_id: fake_uuid(Aggregates::Member, 1) } } 59 | it 'includes member_id' do 60 | assert_equal(subject.payload['member_id'], member[:member_id]) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /app/aggregates/registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'mixins/attributes' 4 | 5 | module Aggregates 6 | ## 7 | # A +Registration+ represents a person who has registered but is not a 8 | # +Member+ yet. 9 | # It will become a +Member+ on finishing the registration. 10 | class Registration 11 | include EventSourcery::AggregateRoot 12 | include Attributes 13 | 14 | # A Registration can only be confirmed once. 15 | AlreadyConfirmedError = Class.new(StandardError) 16 | # Only one email is allowed to be sent per Registration Aggregate 17 | EmailAlreadySentError = Class.new(StandardError) 18 | 19 | def initialize(*arguments) 20 | @confirmation_email_sent = false 21 | @confirmed = false 22 | 23 | super(*arguments) 24 | end 25 | 26 | apply ConfirmationEmailSent do 27 | @confirmation_email_sent = true 28 | end 29 | 30 | apply RegistrationRequested do |event| 31 | write_attributes(event.body.slice('username', 'password', 'email')) 32 | end 33 | 34 | apply RegistrationConfirmed do 35 | @confirmed = true 36 | end 37 | 38 | # Request a new registration. Depending on settings and current 39 | # state, this might lead to a new member eventually; if the request 40 | # is acknowledged. 41 | def request(payload) 42 | raise EmailAlreadySentError if @confirmation_email_sent 43 | 44 | apply_event( 45 | RegistrationRequested, 46 | aggregate_id: id, 47 | body: payload 48 | ) 49 | self 50 | end 51 | 52 | # Confirm a new registration. This causes the registration to be finalized. 53 | def confirm(payload) 54 | raise AlreadyConfirmedError if @confirmed 55 | 56 | apply_event( 57 | RegistrationConfirmed, 58 | aggregate_id: id, 59 | body: payload.merge(attributes) 60 | ) 61 | self 62 | end 63 | 64 | def email 65 | attributes[:email] 66 | end 67 | 68 | def password 69 | attributes[:password] 70 | end 71 | 72 | def username 73 | attributes[:username] 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /app/commands/session/start.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'app/aggregates/session' 4 | require 'bcrypt' 5 | 6 | module Commands 7 | module Session 8 | # Starting a session, AKA "logging in" 9 | module Start 10 | ## 11 | # Command to start a session. 12 | # TODO: a command is overkill, unless we want to handle logins 13 | # with e.g. a rate limiter, or notification mail or so. 14 | class Command < ApplicationCommand 15 | include BCrypt 16 | UUID_USERNAME_NAMESPACE = UUIDTools::UUID.parse( 17 | 'fb0f6f73-a16d-4032-b508-16519fb4a73a' 18 | ) 19 | DEFAULT_PARAMS = { 'username' => '', 'password' => '' }.freeze 20 | 21 | def initialize(params, projection: Projections::Members::Query) 22 | @payload = DEFAULT_PARAMS.merge(params).slice(*DEFAULT_PARAMS.keys) 23 | @aggregate_id = aggregate_id 24 | 25 | @projection = projection 26 | end 27 | 28 | def validate 29 | return if (pw = member[:password]) && 30 | (Password.new(pw) == payload['password']) 31 | 32 | raise_bad_request( 33 | 'Could not log in. Is the username and password correct?' 34 | ) 35 | end 36 | 37 | def aggregate_id 38 | @aggregate_id ||= uuid_v5 39 | end 40 | 41 | def payload 42 | super.merge('member_id' => member[:member_id]) 43 | end 44 | 45 | private 46 | 47 | attr_reader :projection 48 | 49 | def aggregate_id_name 50 | @payload['username'] 51 | end 52 | 53 | def aggregate_id_namespace 54 | UUID_USERNAME_NAMESPACE 55 | end 56 | 57 | def member 58 | @member ||= projection.find_by(username: @payload['username']) || {} 59 | end 60 | end 61 | 62 | ## 63 | # Handlels the Session::Start::Command 64 | class CommandHandler < ApplicationCommandHandler 65 | def aggregate_class 66 | Aggregates::Session 67 | end 68 | 69 | def aggregate_method 70 | :start 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/integration/web/member_tags_member_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a member using the web-app 7 | # When I visit another members' profile 8 | # And I click the "add tag" button 9 | # And I fill provide a tag 10 | # Then the tag is added to that profile 11 | # And I follow the member 12 | class MemberTagsMemberTest < Minitest::WebSpec 13 | before do 14 | harry 15 | ron 16 | 17 | as(harry) 18 | discover_member(username: ron[:username]).upto(:profile_visited) 19 | 20 | tags_member.upto(:tag_added) 21 | end 22 | 23 | it 'adds a tag to another profile' do 24 | assert_content(flash(:success), '@ron@example.com was tagged as "friend"') 25 | discover_member(username: ron[:username]).upto(:profile_visited) 26 | 27 | assert_selector('.tag.mine', text: 'friend') 28 | end 29 | 30 | it 'shows tags from other members styled different' do 31 | as(hermoine) 32 | discover_member(username: ron[:username]).upto(:profile_visited) 33 | 34 | assert_selector('.tag', text: 'friend') 35 | refute_selector('.tag.mine') 36 | end 37 | 38 | # TODO: for accountability, we need to show the tag.authors in a neat 39 | # and friendly hover dialog. 40 | # Then we can test that a member who tags multiple times, only appears once 41 | # in the authors. One tag "friend" per author, so to say. 42 | 43 | it 'profile can be tagged multiple times with one tag' do 44 | as(hermoine) 45 | discover_member(username: ron[:username]).upto(:profile_visited) 46 | tags_member.upto(:tag_added) 47 | 48 | # Tags shows up as "mine" but it still appears only once: hpotter and mine 49 | # are merged. One .tag is used for the "add" button, making total 2. 50 | assert_selector('.tag.mine', text: 'friend') 51 | assert_equal(find_all('.tag').length, 2) 52 | end 53 | 54 | it 'follows the tagged member' do 55 | # Determine that harry follows ron by checking the notification sent to 56 | # ron. TODO: Change to check with my followings once we have that overview 57 | as(ron) 58 | 59 | main_menu('Updates').click 60 | assert_content '@hpotter@example.com started following you' 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/aggregates/member_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require_relative '../support/shared/attribute_behaviour' 6 | 7 | module Aggregates 8 | ## 9 | # Unit test for the more complex logic in Member Aggregate 10 | class MemberTest < Minitest::Spec 11 | include AttributeBehaviour 12 | 13 | let(:id) { fake_uuid(Aggregates::Member, 1) } 14 | 15 | subject do 16 | Aggregates::Member.new(id, []) 17 | end 18 | 19 | it_behaves_as_attribute_setter(:update_bio, 'bio', MemberBioUpdated) 20 | it_behaves_as_attribute_setter(:update_name, 'name', MemberNameUpdated) 21 | 22 | it_sets_attribute(:add_member, 'email') 23 | it_sets_attribute(:add_member, 'name') 24 | it 'add_member sets handle from username' do 25 | subject.add_member('username' => 'harry') 26 | assert_equal(subject.handle, Handle.new('harry')) 27 | end 28 | 29 | it '#add_tag adds a tag' do 30 | author_id = fake_uuid(Aggregates::Member, 2) 31 | subject.add_tag('author_id' => author_id, 'tag' => 'friend') 32 | 33 | assert_includes( 34 | subject.tags_for(author_id), 35 | Aggregates::Member::Tag.new('friend', fake_uuid(Aggregates::Member, 2)) 36 | ) 37 | end 38 | 39 | it '#add_tag merges with tags with same names' do 40 | author_id = fake_uuid(Aggregates::Member, 2) 41 | subject.add_tag('author_id' => author_id, 'tag' => 'friend') 42 | 43 | other_author_id = fake_uuid(Aggregates::Member, 3) 44 | subject.add_tag('author_id' => other_author_id, 'tag' => 'friend') 45 | 46 | assert_equal(subject.tags_for(author_id).length, 1) 47 | assert_includes( 48 | subject.tags_for(author_id).first.authors, 49 | other_author_id 50 | ) 51 | end 52 | 53 | it 'MemberAdded sets added attribute to true' do 54 | assert(Aggregates::Member.new(id, [MemberAdded.new]).attributes[:added]) 55 | end 56 | 57 | it 'to_h returns attributes' do 58 | assert_equal({ aggregate_id: id }, subject.to_h) 59 | end 60 | 61 | describe 'active?' do 62 | it 'is true when added is true' do 63 | assert(Aggregates::Member.new(id, [MemberAdded.new]).active?) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/integration/web/manage_profile_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a member using the web-app 7 | # When someone upates their profile 8 | # Then I want to be notified 9 | # So that I know what is happening in my network. 10 | class MemberManagesProfileTest < Minitest::WebSpec 11 | let(:bio) { 'Fought a snakey guy, now proud father and civil servant' } 12 | let(:public_name) { 'Harry James Potter' } 13 | 14 | before { harry && ron } 15 | 16 | it 'changes the public biography and name' do 17 | as(harry) do 18 | manages = manage_profile(name: public_name, bio: bio) 19 | manages.upto(:profile_visited) 20 | refute_content bio 21 | refute_content public_name 22 | 23 | manages.upto(:profile_updated) 24 | 25 | # Refresh by browsing to page again. 26 | manages.upto(:profile_visited) 27 | assert_content bio 28 | assert_content public_name 29 | end 30 | end 31 | 32 | it 'cannot update when not logged in' do 33 | visit '/profile' 34 | assert_equal(page.status_code, 403) 35 | assert_content(page, 'You are not logged in') 36 | visit '/profile/edit' 37 | assert_equal(page.status_code, 403) 38 | assert_content(page, 'You are not logged in') 39 | # We might want to test that PUT profile does requires authentication, 40 | # but that is hard to test from the interface, since you cannot get 41 | # to the form making the PUT without being logged in. 42 | end 43 | 44 | it 'notifies you and all other members on the instance of bio update' do 45 | as(harry) 46 | manage_profile(bio: bio).upto(:bio_updated) 47 | main_menu('Updates').click 48 | assert_content 'hpotter@example.com' 49 | 50 | as(ron) 51 | main_menu('Updates').click 52 | assert_content "hpotter@example.com #{Date.today}" 53 | # Until harry has changed their name, we render their handle 54 | assert_content "@hpotter@example.com updated their bio to #{bio}" 55 | end 56 | 57 | it 'does not notify other members on the instance of name update' do 58 | as(harry) 59 | manage_profile(name: public_name).upto(:bio_updated) 60 | 61 | as(ron) 62 | main_menu('Updates').click 63 | refute_selector('.update') 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/commands/registration/new_registration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'bcrypt' 5 | 6 | class NewRegistrationCommandTest < Minitest::Spec 7 | let(:subject_class) { Commands::Registration::NewRegistration::Command } 8 | subject { subject_class.new(params) } 9 | 10 | describe 'with email' do 11 | let(:params) { { 'email' => 'harry@example.com' } } 12 | let(:uuid_v5_for_email) { '377fc540-ff6b-5ddc-9ad8-1d9e9917f626' } 13 | 14 | it 'generates a UUIDv5 for this email' do 15 | assert_equal(uuid_v5_for_email, subject.aggregate_id) 16 | end 17 | end 18 | 19 | describe 'without email' do 20 | let(:params) { { 'email' => '' } } 21 | 22 | it 'has no aggregate_id' do 23 | assert_equal(subject.aggregate_id, '') 24 | end 25 | end 26 | 27 | describe 'password' do 28 | let(:password) { 'caput draconis' } 29 | subject { subject_class.new('password' => password) } 30 | 31 | it 'generates a secure hash of the password' do 32 | assert_equal( 33 | BCrypt::Password.new(subject.payload['password']), 34 | password 35 | ) 36 | end 37 | end 38 | 39 | describe 'validate' do 40 | let(:valid_params) do 41 | { 42 | 'email' => 'harry@example.com', 43 | 'usernane' => 'hpotter', 44 | 'password' => 'caput draconis' 45 | } 46 | end 47 | 48 | it 'raises BadRequest when email is empty' do 49 | assert_raises(BadRequest, 'email is blank') do 50 | subject_class.new(valid_params.merge('email' => '')).validate 51 | end 52 | end 53 | 54 | it 'raises BadRequest when email is nil' do 55 | assert_raises(BadRequest, 'email is blank') do 56 | subject_class.new(valid_params.merge('email' => nil)).validate 57 | end 58 | end 59 | 60 | it 'raises BadRequest when username is empty' do 61 | assert_raises(BadRequest, 'username is blank') do 62 | subject_class.new(valid_params.merge('username' => '')).validate 63 | end 64 | end 65 | 66 | it 'raises BadRequest when password is empty' do 67 | assert_raises(BadRequest, 'password is blank') do 68 | subject_class.new(valid_params.merge('password' => '')).validate 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Test 9 | 10 | on: 11 | push: 12 | branches: [ main, develop ] 13 | pull_request_target: 14 | types: [labeled] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | if: contains(github.event.pull_request.labels.*.name, 'safe to test') 21 | 22 | ## We don't need encrypted secrets yet for booting a temp database container. 23 | env: 24 | APP_ENV: test 25 | MAIL_METHOD: test 26 | JWT_SECRET: 's3cr37' 27 | PORT: 3000 28 | DB_USER: postgres 29 | DB_HOST: localhost 30 | DB_PASSWORD: 'postgres' 31 | DB_NAME: roost_test 32 | DB_PORT: 5432 33 | DATABASE_URL: postgres://postgres:postgres@localhost:5432/roost_test 34 | 35 | services: 36 | postgres: 37 | image: postgres 38 | env: 39 | POSTGRES_PASSWORD: 'postgres' 40 | options: >- 41 | --health-cmd pg_isready 42 | --health-interval 10s 43 | --health-timeout 5s 44 | --health-retries 5 45 | ports: 46 | - 5432:5432 47 | 48 | steps: 49 | - uses: actions/checkout@v2 50 | # Please refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests 51 | # for issues this can cause. We mitigate it with requiring a tag to be set. Which only maintainers can add. 52 | with: 53 | ref: ${{ github.event.pull_request.head.sha }} 54 | - name: Set up Ruby 55 | uses: ruby/setup-ruby@v1 56 | with: 57 | ruby-version: 2.6 58 | - name: Install Dependencies 59 | run: bundle install 60 | - name: Setup Database 61 | run: make _db-setup 62 | - name: Run tests and upload coverage results 63 | uses: paambaati/codeclimate-action@v2.7.4 64 | with: 65 | coverageCommand: make 66 | env: 67 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 68 | -------------------------------------------------------------------------------- /test/integration/api/member_authenticates_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a client using the API 7 | # When my session was ended 8 | # Then I want to log in on behalf of a member 9 | # So that I can authenticate and act as a member 10 | class MemberAuthenticatesTest < Minitest::ApiSpec 11 | describe 'GET /session' do 12 | let(:workflow) { Workflows::AddMember.new(self) } 13 | # Override the secret so we can check that it is integrated properly. e.g. 14 | # avoid testing nil=nil as secret. 15 | def secret 16 | 's3cr37' 17 | end 18 | 19 | before do 20 | workflow.call 21 | end 22 | 23 | # TODO: we will need to move to OAUTH2 to make this work safely in practice. 24 | # but, for the PoC, the naive token implementation works. THIS IS INSECURE. 25 | describe 'with valid token' do 26 | it 'shows my current session details' do 27 | token = jwt.encode(authentication_payload, secret, 'HS256') 28 | header 'Authorization', "Bearer #{token}" 29 | get '/api/session' 30 | assert_status(200) 31 | assert_equal( 32 | { 33 | aggregate_id: workflow.aggregate_id, 34 | name: workflow.member_name, 35 | email: workflow.member_email, 36 | handle: '@@example.com' 37 | }, 38 | parsed_response 39 | ) 40 | end 41 | end 42 | 43 | describe 'with invalid aggregate_id in token' do 44 | it 'returns an empty member body' do 45 | authentication_payload[:sub] = fake_uuid(Aggregates::Member, 1) 46 | token = jwt.encode(authentication_payload, secret, 'HS256') 47 | header 'Authorization', "Bearer #{token}" 48 | get '/api/session' 49 | assert_status(200) 50 | assert_equal( 51 | parsed_response, 52 | { aggregate_id: fake_uuid(Aggregates::Member, 1) } 53 | ) 54 | end 55 | end 56 | 57 | describe 'with invalid token' do 58 | it 'gives access denied' do 59 | token = jwt.encode(authentication_payload, 'WRONG', 'HS256') 60 | header 'Authorization', "Bearer #{token}" 61 | get '/api/session' 62 | assert_status(401) 63 | end 64 | end 65 | 66 | describe 'without token' do 67 | it 'gives access denied' do 68 | header 'Authorization', nil 69 | get '/api/session' 70 | assert_status(401) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift '.' 4 | 5 | task :environment do 6 | require 'config/environment' 7 | end 8 | 9 | task :database do 10 | require 'config/database' 11 | end 12 | 13 | desc 'Run Event Stream Processors' 14 | task run_processors: %i[environment database] do 15 | puts 'Starting Event Stream processors' 16 | 17 | event_source = Roost.event_source 18 | tracker = Roost.tracker 19 | db_connection = Roost.projections_database 20 | 21 | # Need to disconnect before starting the processors 22 | # to ensure each forked process has its own connection 23 | db_connection.disconnect 24 | 25 | # Show our ESP logs in foreman immediately 26 | $stdout.sync = true 27 | 28 | processors = Roost.all_processors.map do |processor_class| 29 | processor_class.new(tracker: tracker, db_connection: db_connection) 30 | end 31 | 32 | EventSourcery::EventProcessing::ESPRunner.new( 33 | event_processors: processors, 34 | event_source: event_source 35 | ).start! 36 | end 37 | 38 | namespace :db do 39 | desc 'Create database' 40 | task create: :environment do 41 | begin 42 | database.run("CREATE DATABASE #{database_name}") 43 | rescue StandardError => e 44 | puts "Could not create database '#{database_name}': #{e.class.name}"\ 45 | "#{e.message}" 46 | end 47 | database.disconnect 48 | end 49 | 50 | desc 'Drop database' 51 | task drop: :environment do 52 | database.run("DROP DATABASE IF EXISTS #{database_name}") 53 | database.disconnect 54 | end 55 | 56 | desc 'Migrate database' 57 | task migrate: %i[environment database] do 58 | database = EventSourcery::Postgres.config.event_store_database 59 | begin 60 | EventSourcery::Postgres::Schema.create_event_store(db: database) 61 | rescue StandardError => e 62 | puts "Could not create event store: #{e.class.name} #{e.message}" 63 | end 64 | end 65 | 66 | desc 'Setup Event Stream projections' 67 | task create_projections: %i[environment database] do 68 | Roost.all_processors.map(&:new).each(&:setup) 69 | end 70 | 71 | desc 'Reset Event Stream projections' 72 | task reset_projections: %i[environment database] do 73 | Roost.all_processors.map(&:new).each(&:reset) 74 | end 75 | 76 | def database 77 | Sequel.connect URI.join(url, '/template1').to_s 78 | end 79 | 80 | def database_name 81 | File.basename(url) 82 | end 83 | 84 | def url 85 | Roost.config.database_url 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/integration/web/visitor_registers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | ## 6 | # As a visitor 7 | # When I decide to join the community 8 | # Then I need to register and confirm my emailaddress 9 | # So that I have login credentials 10 | # And so that no-one can use my email as if they are me. 11 | class VisitorRegistersTest < Minitest::WebSpec 12 | describe 'with open registrations' do 13 | it 'sends an email' do 14 | member_registers.upto(:registered) 15 | 16 | assert_content( 17 | find('.notification'), 18 | 'Registration email sent. Please check your spam folder too' 19 | ) 20 | 21 | assert_mail_deliveries(1) 22 | assert_includes(email.to, 'harry@hogwards.edu.wiz') 23 | assert_match( 24 | /Welcome to Flockingbird. Please confirm your email address/, 25 | email.subject 26 | ) 27 | assert_match(%r{http.*/confirmation/[0-9a-f-]+}, email.body.to_s) 28 | end 29 | 30 | it 'confirms the email by clicking the link in the email' do 31 | member_registers.upto(:confirmed) 32 | 33 | assert_content( 34 | find('.notification'), 35 | 'Email address confirmed. Welcome!' 36 | ) 37 | end 38 | 39 | it 'can register only once per email address' do 40 | workflow = member_registers 41 | workflow.upto(:registered) 42 | assert_mail_deliveries(1) 43 | 44 | workflow.upto(:registered) 45 | assert_mail_deliveries(1) # Still one, no new mails 46 | 47 | assert_content( 48 | find('.notification.is-error'), 49 | 'Emailaddress is already registered. Do you want to login instead?'\ 50 | ) 51 | end 52 | 53 | it 'can confirm only once' do 54 | workflow = member_registers 55 | workflow.upto(:confirmed) 56 | 57 | # Confirm again 58 | workflow.confirmed 59 | 60 | assert_content( 61 | find('.notification.is-error'), 62 | 'Could not confirm. Maybe the link in the email expired, or was'\ 63 | ' already used?' 64 | ) 65 | end 66 | 67 | it 'must provide all attributes' do 68 | # We only test with a missing username 69 | member_registers({ username: '' }).upto(:registered) 70 | 71 | assert_content( 72 | find('.notification.is-error'), 73 | 'username is blank'\ 74 | ) 75 | end 76 | end 77 | 78 | describe 'with invite-only' do 79 | before { skip 'implement setting' } 80 | end 81 | 82 | def email 83 | deliveries.last 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/rfc.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: RFC 3 | about: Feature Requests, Proposals, ideas and concepts are discussed by starting a new RFC 4 | title: 'RFC [description]' 5 | labels: 'rfc' 6 | assignees: '' 7 | 8 | --- 9 | ## Summary 10 | 11 | Brief explanation of the feature. 12 | 13 | ## Basic example 14 | 15 | Preferably in the form of a story with a stakeholder (fictional person) 16 | 17 | As a ... 18 | When I ... 19 | And I ... 20 | Then I ... 21 | So that I ... 22 | 23 | ## Motivation 24 | 25 | Why are we doing this? What use cases does it support? What is the expected 26 | outcome? 27 | 28 | Please focus on explaining the motivation so that if this RFC is not accepted, 29 | the motivation could be used to develop alternative solutions. In other words, 30 | enumerate the constraints you are trying to solve without coupling them too 31 | closely to the solution you have in mind. 32 | 33 | ## Detailed design 34 | 35 | This is the bulk of the RFC. Explain the design in enough detail for 36 | somebody familiar with the Fediverse and Flockingbird to understand, and 37 | for somebody familiar with the implementation to implement. This should 38 | get into specifics and corner-cases, and include examples of how the 39 | feature is used. Any new terminology should be defined here. 40 | 41 | Please reference [Domain 42 | Model (TODO write)](https://github.com/Flockingbird/roost/wiki/DomainModel) 43 | terminology for existing terms and usage. 44 | 45 | ## Drawbacks 46 | 47 | Why should we *not* do this? Please consider: 48 | 49 | - implementation cost, both in term of code size and complexity 50 | - integration of this feature with other existing and planned features 51 | - the impact on server admins, hosters and users 52 | - whether the proposed feature can be implemented in a third party app 53 | or client instead 54 | 55 | There are trade-offs to choosing any path. Attempt to identify them here. 56 | 57 | ## Alternatives 58 | 59 | What other designs have been considered? What is the impact of not doing this? 60 | 61 | ## Adoption strategy 62 | 63 | If we implement this proposal, how will server admins adopt it? Is 64 | this a breaking change? Does it require coordination with third party 65 | clients? Does it require coordination with other Fediverse projects? 66 | 67 | ## How we teach this 68 | 69 | What names and terminology work best for these concepts and why? 70 | 71 | ## Unresolved questions 72 | 73 | Optional, but suggested for first drafts. What parts of the design are still 74 | TBD? 75 | 76 | --- 77 | Footnotes and references 78 | 79 | --- 80 | This RFC template is modified from the [React RFC 81 | template](https://github.com/reactjs/rfcs/blob/master/0000-template.md) 82 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'database_cleaner' 4 | 5 | require 'awesome_print' 6 | require 'byebug' 7 | require 'capybara/minitest' 8 | require 'ostruct' 9 | 10 | require_relative 'support/data_helpers' 11 | require_relative 'support/event_helpers' 12 | require_relative 'support/file_helpers' 13 | require_relative 'support/mail_helpers' 14 | require_relative 'support/request_helpers' 15 | require_relative 'support/time_helpers' 16 | require_relative 'support/web_test_helpers' 17 | require_relative 'support/workflows' 18 | 19 | require 'simplecov' 20 | SimpleCov.start 21 | 22 | require 'minitest/autorun' 23 | 24 | ENV['APP_ENV'] = ENV['RACK_ENV'] = 'test' 25 | $LOAD_PATH << '.' 26 | 27 | require 'rack/server' 28 | require 'config/environment' 29 | require 'config/database' 30 | 31 | Dir.glob("#{__dir__}/../app/aggregates/*.rb").sort.each { |f| require f } 32 | Dir.glob("#{__dir__}/../app/projections/**/query.rb") 33 | .sort 34 | .each { |f| require f } 35 | 36 | require_relative '../app/commands/application_command' 37 | require_relative '../app/commands/application_command_handler' 38 | Dir.glob("#{__dir__}/../app/commands/**/*.rb").sort.each { |f| require f } 39 | 40 | Minitest::Test.make_my_diffs_pretty! 41 | 42 | module Minitest 43 | class Spec 44 | include DataHelpers 45 | include EventHelpers 46 | include FileHelpers 47 | include MailHelpers 48 | include RequestHelpers 49 | include TimeHelpers 50 | include Workflows 51 | 52 | EventSourcery.configure do |config| 53 | config.logger = Logger.new(nil) 54 | end 55 | 56 | DatabaseCleaner[:sequel, connection: Roost.event_store] 57 | DatabaseCleaner[:sequel, connection: Roost.projections_database] 58 | DatabaseCleaner[:sequel].strategy = :truncation 59 | 60 | before :each do 61 | DatabaseCleaner[:sequel].start 62 | setup_processors 63 | deliveries.clear 64 | end 65 | 66 | after :each do 67 | DatabaseCleaner[:sequel].clean 68 | end 69 | 70 | def app 71 | # Simulate a rackup using the config and routing in config.ru 72 | Rack::Server.new(config: Roost.root.join('config.ru').to_s).app 73 | end 74 | end 75 | 76 | class WebSpec < Spec 77 | include Capybara::DSL 78 | include Capybara::Minitest::Assertions 79 | include WebTestHelpers 80 | 81 | def setup 82 | Capybara.app = app 83 | Capybara.default_driver = :rack_test 84 | Capybara.save_path = Roost.root.join('tmp') 85 | super 86 | end 87 | 88 | def teardown 89 | Capybara.reset_sessions! 90 | Capybara.use_default_driver 91 | super 92 | end 93 | end 94 | 95 | class ApiSpec < Spec 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roost 2 | 3 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/d27492e9817263c1e9b3/maintainability)](https://codeclimate.com/github/Flockingbird/roost/maintainability) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/d27492e9817263c1e9b3/test_coverage)](https://codeclimate.com/github/Flockingbird/roost/test_coverage) 6 | 7 | 8 | [Flockingbird](https://flockingbird.social) is a professional social network, 9 | where you manage your business network. Decentralised, and privacy friendly. 10 | 11 | *Roost* is the Proof of Concept server and webapp for Flockingbird. 12 | 13 | More information: 14 | 15 | * [Landing page](https://flockingbird.social) to be expanded homepage 16 | * [Blog](https://fediverse.blog/~/Flockingbird/) updates with progress, concepts and explanations 17 | * [Mastodon](https://fosstodon.org/@flockingbird) updates with newsflashes and feedback 18 | 19 | 20 | ## Contribute 21 | 22 | If you want to help, the easiest and most effective thing to do is tell 23 | others about us: spread the word! 24 | 25 | Other than that, any help is welcome. From giving unsolicited advice, 26 | via designing mockups, via improving this README, to writing code. 27 | 28 | ### Designs and Mockups 29 | 30 | We are moving the designs from [our Figma](https://www.figma.com/file/CgDIaLgjwVLPzw1ggmrZzy/Flockingbird) to 31 | the Open Source alternative [Penpot](https://penpot.app/). Please drop us an 32 | email at hi@flockingbird.social if you want to be added as team member 33 | on the Flockingbird team in penpot. 34 | 35 | Anyone can [view the designs (WIP) on penpot](https://design.penpot.app/#/view/f0afd050-8431-11eb-9126-57893f4da933/f0aff760-8431-11eb-9126-57893f4da933?token=TJpT2QZ1wODw6KLDzLLUKw&index=0) 36 | 37 | ### Ideas 38 | 39 | Ideas are most welcome! Either add a [new 40 | RFC](https://github.com/Flockingbird/roost/issues/new?assignees=&labels=rfc&template=rfc.md&title=RFC+%5Bdescription%5D) 41 | or drop us a mail at hi@flockingbird or a toot at @flockingbird@fosstodon.org. 42 | 43 | ### Tasks and code 44 | 45 | We are creating a list of tasks for various interests, and skills in 46 | our issues (helping with this list is most welcome too!) 47 | 48 | Use the labels to find something that fits for you. For example [tasks for frontend developers](https://github.com/Flockingbird/roost/issues?q=is%3Aissue+is%3Aopen+label%3Afrontend). 49 | 50 | 51 | ## Get started 52 | 53 | Ensure you have Postgres and Ruby 2.3 or higher installed, then run the setup script: 54 | 55 | ```sh 56 | make install 57 | ``` 58 | 59 | ## Using the Application 60 | 61 | Start the web server and processors (reactors and projectors): 62 | 63 | ```sh 64 | make run 65 | ``` 66 | 67 | ## Develop 68 | 69 | We use [envent_sourcery](https://github.com/envato/event_sourcery) by 70 | Envato. If unsure "where something goes", just ask, or read up on event 71 | sourcing starting at event_sourcery README. 72 | 73 | Make sure to add tests for any feature or bugfix. 74 | 75 | Test with 76 | ```sh 77 | make 78 | ``` 79 | 80 | This also enforces some code style guidelines once the tests pass. 81 | -------------------------------------------------------------------------------- /app/aggregates/member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'mixins/attributes' 4 | require_relative 'member/tag' 5 | require_relative 'member/tag_list' 6 | 7 | require 'lib/aggregate_equality' 8 | 9 | module Aggregates 10 | ## 11 | # A +Member+ is a registered, available account on an +Instance+. 12 | # This can be a human, bot, or other actor. It can login, has a profile 13 | # and can interact with other members on this and other +Instances+ 14 | class Member 15 | include EventSourcery::AggregateRoot 16 | include AggregateEquality 17 | include Attributes 18 | 19 | def initialize(id, events) 20 | @tags = TagList.new 21 | super(id, events) 22 | end 23 | 24 | apply MemberAdded do |event| 25 | username = event.body['username'] 26 | write_attributes( 27 | added: true, 28 | handle: Handle.new(username), 29 | email: event.body['email'], 30 | name: event.body['name'] 31 | ) 32 | end 33 | 34 | apply MemberInvited do |event| 35 | end 36 | apply FollowerAdded do |event| 37 | end 38 | 39 | apply MemberBioUpdated do |event| 40 | write_attributes(event.body.slice('bio')) 41 | end 42 | 43 | apply MemberNameUpdated do |event| 44 | write_attributes(event.body.slice('name')) 45 | end 46 | 47 | apply MemberTagAdded do |event| 48 | @tags << Tag.new(event.body['tag'], event.body['author_id']) 49 | end 50 | 51 | def add_member(payload) 52 | apply_event(MemberAdded, aggregate_id: id, body: payload) 53 | self 54 | end 55 | 56 | def invite_member(payload) 57 | apply_event(MemberInvited, aggregate_id: id, body: payload) 58 | self 59 | end 60 | 61 | def update_bio(payload) 62 | new_bio = payload.slice('bio') 63 | return self if bio == new_bio['bio'].to_s 64 | 65 | apply_event(MemberBioUpdated, aggregate_id: id, body: new_bio) 66 | self 67 | end 68 | 69 | def update_name(payload) 70 | new_name = payload.slice('name') 71 | return self if name == new_name['name'].to_s 72 | 73 | apply_event(MemberNameUpdated, aggregate_id: id, body: new_name) 74 | self 75 | end 76 | 77 | def add_tag(payload) 78 | body = payload.slice('author_id', 'tag') 79 | apply_event(MemberTagAdded, aggregate_id: id, body: body) 80 | self 81 | end 82 | 83 | attr_reader :id 84 | 85 | def active? 86 | attributes.fetch(:added, false) 87 | end 88 | 89 | def null? 90 | false 91 | end 92 | 93 | def member_id 94 | id 95 | end 96 | 97 | def bio 98 | attributes[:bio] 99 | end 100 | 101 | def name 102 | attributes[:name] 103 | end 104 | 105 | def handle 106 | attributes[:handle] 107 | end 108 | 109 | def email 110 | attributes[:email] 111 | end 112 | 113 | def invitation_token 114 | id 115 | end 116 | 117 | # TODO: implement per-author tags 118 | def tags_for(_member) 119 | tags 120 | end 121 | 122 | private 123 | 124 | attr_reader :tags 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mail' 4 | require 'uuidtools' 5 | require 'event_sourcery' 6 | require 'event_sourcery/postgres' 7 | 8 | require "#{__dir__}/../app/errors" 9 | 10 | Dir.glob("#{__dir__}/../lib/*.rb").sort.each { |f| require f } 11 | Dir.glob("#{__dir__}/../app/events/*.rb").sort.each { |f| require f } 12 | Dir.glob("#{__dir__}/../app/reactors/*.rb").sort.each { |f| require f } 13 | Dir.glob("#{__dir__}/../app/projections/**/projector.rb").sort.each do |f| 14 | require f 15 | end 16 | 17 | ## 18 | # The main app, integrated under Roost 19 | class Roost 20 | ## 21 | # Holds the configuration for Roost. Mainly event-sourcery config. 22 | class Config 23 | attr_accessor :database_url, :secret_base, :web_url 24 | end 25 | 26 | def self.config 27 | @config ||= Config.new 28 | end 29 | 30 | def self.configure 31 | yield config 32 | end 33 | 34 | def self.production? 35 | environment == 'production' 36 | end 37 | 38 | def self.development? 39 | environment == 'development' 40 | end 41 | 42 | def self.test? 43 | environment == 'test' 44 | end 45 | 46 | def self.environment 47 | ENV.fetch('APP_ENV', 'development') 48 | end 49 | 50 | def self.root 51 | Pathname.new(File.expand_path("#{__dir__}/../")) 52 | end 53 | 54 | def self.event_store 55 | EventSourcery::Postgres.config.event_store 56 | end 57 | 58 | def self.event_source 59 | EventSourcery::Postgres.config.event_store 60 | end 61 | 62 | def self.tracker 63 | EventSourcery::Postgres.config.event_tracker 64 | end 65 | 66 | def self.event_sink 67 | EventSourcery::Postgres.config.event_sink 68 | end 69 | 70 | def self.projections_database 71 | EventSourcery::Postgres.config.projections_database 72 | end 73 | 74 | def self.all_processors 75 | [ 76 | Reactors::InvitationMailer, 77 | Reactors::ConfirmationMailer, 78 | Reactors::MemberGenerator, 79 | Reactors::Follower, 80 | Projections::Contacts::Projector, 81 | Projections::Invitations::Projector, 82 | Projections::Members::Projector, 83 | Projections::Updates::Projector 84 | ] 85 | end 86 | 87 | def self.repository 88 | @repository ||= EventSourcery::Repository.new( 89 | event_source: event_source, 90 | event_sink: event_sink 91 | ) 92 | end 93 | 94 | def self.base_path 95 | Pathname.new(File.join(__dir__, '..')) 96 | end 97 | end 98 | 99 | unless Roost.production? 100 | require 'dotenv' 101 | Dotenv.load(Roost.base_path.join(".env.#{ENV['APP_ENV']}"), 102 | Roost.base_path.join('.env')) 103 | end 104 | 105 | Roost.configure do |config| 106 | config.web_url = 'https://example.com' 107 | config.secret_base = ENV['SECRET_BASE'] 108 | config.database_url = ENV['DATABASE_URL'] 109 | 110 | Mail.defaults do 111 | delivery_method( 112 | ENV['MAIL_METHOD'].to_sym, 113 | address: ENV['SMTP_ADDRESS'], 114 | port: ENV['SMTP_PORT'], 115 | user_name: ENV['SMTP_USER_NAME'], 116 | password: ENV['SMTP_PASSWORD'], 117 | enable_starttls: ENV['SMTP_STARTTLS'], 118 | domain: ENV['SMTP_DOMAIN'] 119 | ) 120 | end 121 | end 122 | 123 | require Roost.root.join('lib/handle') 124 | -------------------------------------------------------------------------------- /app/projections/updates/projector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../members/query' 4 | require 'app/aggregates/member' 5 | 6 | module Projections 7 | module Updates 8 | ## 9 | # Stores the Updates for a member in their distinct query table. 10 | # Denormalised, so each member has their own copy of the updates meant for 11 | # them. This makes it easy to manage wrt non-local origins. And it allows 12 | # for easy, yet performant lookups (no joins needed). 13 | class Projector 14 | include EventSourcery::Postgres::Projector 15 | 16 | projector_name :updates 17 | 18 | # From mastodon, as reference: 19 | # id :bigint(8) not null, primary key 20 | # uri :string 21 | # text :text default(""), not null 22 | # created_at :datetime not null 23 | # updated_at :datetime not null 24 | # in_reply_to_id :bigint(8) 25 | # reblog_of_id :bigint(8) 26 | # url :string 27 | # sensitive :boolean default(FALSE), not null 28 | # visibility :integer default("public"), not null 29 | # spoiler_text :text default(""), not null 30 | # reply :boolean default(FALSE), not null 31 | # language :string 32 | # conversation_id :bigint(8) 33 | # local :boolean 34 | # account_id :bigint(8) not null 35 | # application_id :bigint(8) 36 | # in_reply_to_account_id :bigint(8) 37 | # poll_id :bigint(8) 38 | # deleted_at :datetime 39 | # 40 | # We don't have an ID, but if we need one, it would be distinct for each 41 | # copy of an update: each member has its own distinct record; so an 42 | # original update might be stored multiple times in the database: once 43 | # for every member that can see that update. 44 | table :updates do 45 | column :for, 'UUID NOT NULL' 46 | column :uri, :text 47 | column :author, :text 48 | column :author_uri, :text 49 | column :posted_at, DateTime 50 | column :text, :text 51 | end 52 | 53 | project MemberBioUpdated do |event| 54 | author = Roost.repository.load(Aggregates::Member, event.aggregate_id) 55 | update = BioUpdateRecord.new(event.body.merge(author: author)) 56 | 57 | # Insert a record for each local member. 58 | Members::Query.collection.select(:member_id).each do |attrs| 59 | table.insert( 60 | for: attrs[:member_id], 61 | author: author.handle.to_s, 62 | posted_at: DateTime.now, 63 | text: update.text 64 | ) 65 | end 66 | end 67 | 68 | project ContactAdded do |event| 69 | author = Roost.repository.load( 70 | Aggregates::Member, event.body['owner_id'] 71 | ) 72 | update = AddedContact.new(event.body.merge(author: author)) 73 | recipient_id = Members::Query.aggregate_id_for(event.body['handle']) 74 | 75 | table.insert( 76 | for: recipient_id, 77 | author: author.handle.to_s, 78 | posted_at: DateTime.now, 79 | text: update.text 80 | ) 81 | end 82 | 83 | project FollowerAdded do |event| 84 | author = Roost.repository.load( 85 | Aggregates::Member, event.body['follower_id'] 86 | ) 87 | update = FollowsUpdate.new(event.body.merge(author: author)) 88 | recipient_id = event.aggregate_id 89 | 90 | table.insert( 91 | for: recipient_id, 92 | author: author.handle.to_s, 93 | posted_at: DateTime.now, 94 | text: update.text 95 | ) 96 | end 97 | end 98 | 99 | ## 100 | # Represents a generic Update that can projected 101 | class UpdateRecord < OpenStruct 102 | def text 103 | '' 104 | end 105 | 106 | def author_name 107 | return '' unless author 108 | 109 | name = author.name.to_s 110 | name.empty? ? author.handle : name 111 | end 112 | end 113 | 114 | ## 115 | # Represents an update to someones profile bio that can be projected 116 | class BioUpdateRecord < UpdateRecord 117 | def text 118 | "#{author_name} updated their bio to #{bio}" 119 | end 120 | end 121 | 122 | ## 123 | # Represents an update to someones profile bio that can be projected 124 | class AddedContact < UpdateRecord 125 | def text 126 | "#{author_name} added you to their contacts" 127 | end 128 | end 129 | 130 | ## 131 | # Respresents an update that X is now following you 132 | class FollowsUpdate < UpdateRecord 133 | def text 134 | "#{author_name} started following you" 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/envato/event_sourcery-postgres.git 3 | revision: 9fa5cec446e9335edb5b8d4aa2517d383c73b076 4 | specs: 5 | event_sourcery-postgres (0.8.1) 6 | event_sourcery (>= 0.14.0) 7 | pg 8 | sequel (>= 4.38) 9 | 10 | GIT 11 | remote: https://github.com/envato/event_sourcery.git 12 | revision: 343a0e40040920be9c0fe3692dfde50d5f9407cc 13 | specs: 14 | event_sourcery (0.23.1) 15 | 16 | GIT 17 | remote: https://github.com/envato/event_sourcery_generators.git 18 | revision: 484f862f465c73f07b05ee765b687e3a4bb5b0b3 19 | specs: 20 | event_sourcery_generators (0.2.0) 21 | activesupport (~> 5.1) 22 | thor (~> 0.19) 23 | verbs (~> 2.1) 24 | 25 | GIT 26 | remote: https://github.com/uplisting/rack-jwt.git 27 | revision: 57812347d094425968c342fe386acb482c353237 28 | branch: allow-excluding-root-path 29 | specs: 30 | rack-jwt (0.5.0) 31 | jwt (~> 2.1) 32 | rack 33 | 34 | GEM 35 | remote: https://rubygems.org/ 36 | specs: 37 | activesupport (5.2.4.4) 38 | concurrent-ruby (~> 1.0, >= 1.0.2) 39 | i18n (>= 0.7, < 2) 40 | minitest (~> 5.1) 41 | tzinfo (~> 1.1) 42 | addressable (2.7.0) 43 | public_suffix (>= 2.0.2, < 5.0) 44 | ast (2.4.1) 45 | awesome_print (1.8.0) 46 | bcrypt (3.1.16) 47 | better_errors (2.8.3) 48 | coderay (>= 1.0.0) 49 | erubi (>= 1.0.0) 50 | rack (>= 0.9.0) 51 | byebug (11.1.3) 52 | capybara (3.33.0) 53 | addressable 54 | mini_mime (>= 0.1.3) 55 | nokogiri (~> 1.8) 56 | rack (>= 1.6.0) 57 | rack-test (>= 0.6.3) 58 | regexp_parser (~> 1.5) 59 | xpath (~> 3.2) 60 | coderay (1.1.3) 61 | concurrent-ruby (1.1.7) 62 | database_cleaner (1.8.5) 63 | database_cleaner-sequel (1.8.0) 64 | database_cleaner (~> 1.8.0) 65 | sequel 66 | diff-lcs (1.4.4) 67 | docile (1.3.2) 68 | dotenv (2.7.6) 69 | erubi (1.9.0) 70 | i18n (1.8.5) 71 | concurrent-ruby (~> 1.0) 72 | jaro_winkler (1.5.4) 73 | json (2.3.1) 74 | jwt (2.2.2) 75 | launchy (2.5.0) 76 | addressable (~> 2.7) 77 | mail (2.7.1) 78 | mini_mime (>= 0.1.1) 79 | method_source (1.0.0) 80 | mini_mime (1.0.2) 81 | mini_portile2 (2.4.0) 82 | minitest (5.14.2) 83 | multi_json (1.15.0) 84 | mustermann (1.1.1) 85 | ruby2_keywords (~> 0.0.1) 86 | nokogiri (1.10.10) 87 | mini_portile2 (~> 2.4.0) 88 | parallel (1.19.2) 89 | parser (2.7.2.0) 90 | ast (~> 2.4.1) 91 | pg (0.20.0) 92 | pry (0.13.1) 93 | coderay (~> 1.1) 94 | method_source (~> 1.0) 95 | public_suffix (4.0.6) 96 | rack (2.2.3) 97 | rack-protection (2.1.0) 98 | rack 99 | rack-test (1.1.0) 100 | rack (>= 1.0, < 3) 101 | rainbow (3.0.0) 102 | rake (13.0.1) 103 | regexp_parser (1.8.2) 104 | rexml (3.2.4) 105 | rspec (3.9.0) 106 | rspec-core (~> 3.9.0) 107 | rspec-expectations (~> 3.9.0) 108 | rspec-mocks (~> 3.9.0) 109 | rspec-core (3.9.3) 110 | rspec-support (~> 3.9.3) 111 | rspec-expectations (3.9.2) 112 | diff-lcs (>= 1.2.0, < 2.0) 113 | rspec-support (~> 3.9.0) 114 | rspec-mocks (3.9.1) 115 | diff-lcs (>= 1.2.0, < 2.0) 116 | rspec-support (~> 3.9.0) 117 | rspec-support (3.9.3) 118 | rubocop (0.82.0) 119 | jaro_winkler (~> 1.5.1) 120 | parallel (~> 1.10) 121 | parser (>= 2.7.0.1) 122 | rainbow (>= 2.2.2, < 4.0) 123 | rexml 124 | ruby-progressbar (~> 1.7) 125 | unicode-display_width (>= 1.4.0, < 2.0) 126 | ruby-progressbar (1.10.1) 127 | ruby2_keywords (0.0.2) 128 | sequel (5.37.0) 129 | simplecov (0.17.1) 130 | docile (~> 1.1) 131 | json (>= 1.8, < 3) 132 | simplecov-html (~> 0.10.0) 133 | simplecov-html (0.10.2) 134 | sinatra (2.1.0) 135 | mustermann (~> 1.0) 136 | rack (~> 2.2) 137 | rack-protection (= 2.1.0) 138 | tilt (~> 2.0) 139 | sinatra-contrib (2.1.0) 140 | multi_json 141 | mustermann (~> 1.0) 142 | rack-protection (= 2.1.0) 143 | sinatra (= 2.1.0) 144 | tilt (~> 2.0) 145 | sinatra-flash (0.3.0) 146 | sinatra (>= 1.0.0) 147 | thor (0.20.3) 148 | thread_safe (0.3.6) 149 | tilt (2.0.10) 150 | tzinfo (1.2.7) 151 | thread_safe (~> 0.1) 152 | unicode-display_width (1.7.0) 153 | uuidtools (2.2.0) 154 | verbs (2.2.1) 155 | activesupport (>= 2.3.4) 156 | i18n 157 | xpath (3.2.0) 158 | nokogiri (~> 1.8) 159 | yajl-ruby (1.4.1) 160 | 161 | PLATFORMS 162 | ruby 163 | 164 | DEPENDENCIES 165 | awesome_print 166 | bcrypt 167 | better_errors 168 | byebug 169 | capybara 170 | database_cleaner-sequel 171 | dotenv (~> 2.7) 172 | event_sourcery! 173 | event_sourcery-postgres! 174 | event_sourcery_generators! 175 | launchy 176 | mail 177 | minitest 178 | pg (= 0.20.0) 179 | pry 180 | rack-jwt! 181 | rack-test 182 | rake 183 | rspec 184 | rubocop (~> 0.82.0) 185 | simplecov (~> 0.17.0) 186 | sinatra 187 | sinatra-contrib 188 | sinatra-flash 189 | uuidtools 190 | yajl-ruby 191 | 192 | BUNDLED WITH 193 | 2.1.4 194 | --------------------------------------------------------------------------------