Browse Source

Merge branch 'release/0.3.0'

* release/0.3.0: (225 commits)
  Use local patched gems instead of vanilla
  Package all gems to ensure heroku uses our patched versions
  Include sundays in open_this_week attribute.
  Ensure we handle empty addresses correctly
  Remove unused method from place_id aggregate
  Ensure at_time helper also stubs out Date.today
  Allow make initiator to define the pattern of tests to be ran.
  Add Sunday to opening hours of fixture.
  Render an opening hours table in the detail page.
  Lock babosa -slug- gem in lockfile
  Handle complex region slug-name mapping by looking them up in places.
  Normalize and slugalize regions before making them slugs
  Remove broken and unnessecary assertion.
  Rename the main Id as exposed by Workflows::AddPlace to aggregate_id
  Turn status into :open, :closed or :unknown triplet.
  Render a basic badge with current status on detail page.
  Extract region view tests into own test file.
  Ensure we have an empty-text where we handle empty regions
  Center the map around the geographic center of a region.
  Ensure AddPlace workflow creates places in a line when creating batch
  ...
tags/0.3.0^0
Bèr Kessels 5 months ago
parent
commit
dafdab90a5
100 changed files with 32849 additions and 0 deletions
  1. 7
    0
      .env.test
  2. 50
    0
      .gitignore
  3. 3
    0
      .rubocop.yml
  4. 1
    0
      .ruby-version
  5. 5
    0
      .semver
  6. 56
    0
      Gemfile
  7. 275
    0
      Gemfile.lock
  8. 21
    0
      LICENSE
  9. 58
    0
      Makefile
  10. 2
    0
      Procfile
  11. 101
    0
      README.md
  12. 89
    0
      Rakefile
  13. 80
    0
      app/aggregates/place.rb
  14. 93
    0
      app/aggregates/place/place_id.rb
  15. 44
    0
      app/aggregates/place/region.rb
  16. 1
    0
      app/assets/javascripts/application.js
  17. 13986
    0
      app/assets/javascripts/leaflet-src.esm.js
  18. 1
    0
      app/assets/javascripts/leaflet-src.esm.js.map
  19. 14080
    0
      app/assets/javascripts/leaflet-src.js
  20. 1
    0
      app/assets/javascripts/leaflet-src.js.map
  21. 5
    0
      app/assets/javascripts/leaflet.js
  22. 1
    0
      app/assets/javascripts/leaflet.js.map
  23. 11
    0
      app/assets/stylesheets/application.scss
  24. 640
    0
      app/assets/stylesheets/leaflet.css
  25. 38
    0
      app/commands/add_place_command.rb
  26. 27
    0
      app/commands/command_handler.rb
  27. 5
    0
      app/events/place_added.rb
  28. 5
    0
      app/events/place_proposed.rb
  29. 15
    0
      app/models/base.rb
  30. 36
    0
      app/models/base_collection.rb
  31. 103
    0
      app/models/place.rb
  32. 28
    0
      app/models/place_index.rb
  33. 23
    0
      app/models/region.rb
  34. 61
    0
      app/projections/places.rb
  35. 93
    0
      app/projections/query.rb
  36. 42
    0
      app/serializers/place.rb
  37. 33
    0
      app/views/layout.erb
  38. 57
    0
      app/views/place.erb
  39. 87
    0
      app/views/region.erb
  40. 7
    0
      app/views/status_badge_partial.erb
  41. 11
    0
      bin/console
  42. 1
    0
      bin/imposm
  43. 46
    0
      bin/sink
  44. 7
    0
      config.ru
  45. 23
    0
      config/environment.rb
  46. 25
    0
      config/event_sourcery.rb
  47. 50
    0
      config/poi_catalog.json
  48. 279
    0
      data/country_name.sql
  49. 78
    0
      doc/swagger.yml
  50. BIN
      images/open_openingstijden.png
  51. 103
    0
      images/open_openingstijden.svg
  52. 140
    0
      lib/app.rb
  53. 16
    0
      lib/capture_helpers.rb
  54. 82
    0
      lib/hours.rb
  55. 24
    0
      lib/json_helpers.rb
  56. 29
    0
      lib/pagination_helpers.rb
  57. 58
    0
      mapping.yml
  58. BIN
      public/images/layers-2x.png
  59. BIN
      public/images/layers.png
  60. BIN
      public/images/location-icon.png
  61. BIN
      public/images/marker-icon-2x.png
  62. BIN
      public/images/marker-icon-closed.png
  63. BIN
      public/images/marker-icon-opened.png
  64. BIN
      public/images/marker-icon-unknown.png
  65. BIN
      public/images/marker-icon.png
  66. BIN
      public/images/marker-shadow.png
  67. 99
    0
      test/aggregates/place/place_id_test.rb
  68. 76
    0
      test/aggregates/place/region_test.rb
  69. 62
    0
      test/aggregates/place_test.rb
  70. 20
    0
      test/commands/add_place_command_test.rb
  71. 11
    0
      test/fixtures/input/addressless.json
  72. 22
    0
      test/fixtures/input/aldi_arnhem.json
  73. 16
    0
      test/fixtures/input/durak_market_golcuk.json
  74. 24
    0
      test/fixtures/input/hacked.json
  75. 27
    0
      test/fixtures/input/hm_arnhem.json
  76. 24
    0
      test/fixtures/input/hm_broerstraat.json
  77. 24
    0
      test/fixtures/input/hm_burchtstraat.json
  78. 24
    0
      test/fixtures/input/jan_de_groot_shertogenbosch.json
  79. 21
    0
      test/fixtures/input/marienburg_bicycle.json
  80. 22
    0
      test/fixtures/input/marienburg_parking.json
  81. 19
    0
      test/fixtures/input/supervlaai_t_harde.json
  82. 73
    0
      test/fixtures/output/hm_burchtstraat.json
  83. 69
    0
      test/fixtures/output/places.json
  84. 89
    0
      test/integration/api/add_places_test.rb
  85. 131
    0
      test/integration/api/view_places_test.rb
  86. 38
    0
      test/integration/cli/sink_test.rb
  87. 34
    0
      test/integration/web/browser_views_place_test.rb
  88. 104
    0
      test/integration/web/view_places_test.rb
  89. 94
    0
      test/integration/web/view_regions_test.rb
  90. 48
    0
      test/models/base_collection_test.rb
  91. 7
    0
      test/models/base_test.rb
  92. 22
    0
      test/models/place_index_test.rb
  93. 157
    0
      test/models/place_test.rb
  94. 27
    0
      test/models/region_test.rb
  95. 131
    0
      test/projections/query_test.rb
  96. 27
    0
      test/serializers/place_test.rb
  97. 20
    0
      test/support/data_helpers.rb
  98. 33
    0
      test/support/event_helpers.rb
  99. 11
    0
      test/support/file_helpers.rb
  100. 0
    0
      test/support/location_helpers.rb

+ 7
- 0
.env.test View File

@@ -0,0 +1,7 @@
PORT=3000
# Postgres Config. See https://www.postgresql.org/docs/9.1/libpq-envars.html
DB_USER=postgres
DB_HOST=localhost
DB_PASSWORD=TinLopeTuckBuckTonsVangBeakKink
DB_NAME=hours_test
DB_PORT=5432

+ 50
- 0
.gitignore View File

@@ -0,0 +1,50 @@
*.rbc
/.config
/coverage/
/InstalledFiles
/pkg/
/spec/reports/
/spec/examples.txt
/test/tmp/
/test/version_tmp/
/tmp/

# Used by dotenv library to load environment variables.
.env

## Specific to RubyMotion:
.dat*
.repl_history
build/
*.bridgesupport
build-iPhoneOS/
build-iPhoneSimulator/

## Specific to RubyMotion (use of CocoaPods):
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# vendor/Pods/

## Documentation cache and generated files:
/.yardoc/
/_yardoc/
/doc/
/rdoc/

## Environment normalization:
/.bundle/
/vendor/bundle
/lib/bundler/man/

# for a library or gem, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# Gemfile.lock
# .ruby-version
# .ruby-gemset

# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
.rvmrc
public/proposed_nodes.kml

+ 3
- 0
.rubocop.yml View File

@@ -0,0 +1,3 @@
Metrics/BlockLength:
Exclude:
- test/**/*_test.rb # We define specs in blocks, which by nature get large.

+ 1
- 0
.ruby-version View File

@@ -0,0 +1 @@
2.5.0

+ 5
- 0
.semver View File

@@ -0,0 +1,5 @@
---
:major: 0
:minor: 0
:patch: 0
:special: ''

+ 56
- 0
Gemfile View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true

source 'https://rubygems.org'

git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }

gem 'babosa'
gem 'bootstrap', '~> 4.3'
gem 'erubis'
gem 'event_sourcery'
gem 'event_sourcery-postgres'
gem 'hashie'
gem 'json_expressions'
gem 'jsonapi-rb'
gem 'nominatim', github: 'lukaszsliwa/nominatim'
gem 'offline_geocoder', path: '../libs/offline_geocoder'
gem 'open-location-code', require: 'plus_code'
gem 'opening_hours_converter', path: '../libs/opening_hours_converter'
gem 'pagy'
gem 'rake'
gem 'rgeo-geojson'
gem 'semver'
gem 'sequel-postgis-georuby'
gem 'sinatra'
gem 'sinatra-contrib'
gem 'uuidtools'
gem 'yajl'
gem 'yajl-ruby'

# Asset management
gem 'sass', require: 'sass'
gem 'sprockets'
gem 'uglifier'

group :development, :test do
gem 'dotenv', '~> 2.6'

gem 'capybara'
gem 'database_cleaner'
gem 'minitest'
gem 'minitest-have_tag'
gem 'nokogiri'
gem 'puma' # Used by Capybara to run the web thread
gem 'rack-test'
gem 'selenium-webdriver'
gem 'timecop'

gem 'rubocop'

gem 'awesome_print'
gem 'better_errors'
gem 'byebug'
gem 'pry'

gem 'foreman'
end

+ 275
- 0
Gemfile.lock View File

@@ -0,0 +1,275 @@
GIT
remote: https://github.com/lukaszsliwa/nominatim
revision: dec16d522405cff38f886b8a12199d5b52589774
specs:
nominatim (0.0.6)
faraday
multi_json

PATH
remote: ../libs/offline_geocoder
specs:
offline_geocoder (0.1.0)
geokdtree

PATH
remote: ../libs/opening_hours_converter
specs:
opening_hours_converter (1.13.7)
json

GEM
remote: https://rubygems.org/
specs:
actionpack (5.2.3)
actionview (= 5.2.3)
activesupport (= 5.2.3)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.3)
activesupport (= 5.2.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
activesupport (5.2.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
ast (2.4.0)
autoprefixer-rails (9.6.1.1)
execjs
awesome_print (1.8.0)
babosa (1.0.3)
backports (3.15.0)
better_errors (2.5.0)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
bootstrap (4.3.1)
autoprefixer-rails (>= 9.1.0)
popper_js (>= 1.14.3, < 2)
sassc-rails (>= 2.0.0)
builder (3.2.3)
byebug (10.0.2)
capybara (3.29.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (~> 1.5)
xpath (~> 3.2)
childprocess (3.0.0)
coderay (1.1.2)
concurrent-ruby (1.1.5)
crass (1.0.4)
database_cleaner (1.7.0)
dotenv (2.6.0)
erubi (1.8.0)
erubis (2.7.0)
event_sourcery (0.22.0)
event_sourcery-postgres (0.8.0)
event_sourcery (>= 0.14.0)
pg
sequel (>= 4.38)
execjs (2.7.0)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
ffi (1.11.1)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
foreman (0.85.0)
thor (~> 0.19.1)
geokdtree (0.2.0)
ffi
ffi-compiler
rake
georuby (2.5.2)
hashie (3.6.0)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
jaro_winkler (1.5.2)
json (2.2.0)
json_expressions (0.9.0)
jsonapi-deserializable (0.2.0)
jsonapi-rb (0.5.0)
jsonapi-deserializable (~> 0.2.0)
jsonapi-serializable (~> 0.3.0)
jsonapi-renderer (0.2.0)
jsonapi-serializable (0.3.1)
jsonapi-renderer (~> 0.2.0)
loofah (2.2.3)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
method_source (0.9.2)
mini_mime (1.0.2)
mini_portile2 (2.3.0)
minitest (5.11.3)
minitest-have_tag (0.1.0)
minitest
nokogiri
multi_json (1.13.1)
multipart-post (2.0.0)
mustermann (1.0.3)
nio4r (2.5.2)
nokogiri (1.8.5)
mini_portile2 (~> 2.3.0)
open-location-code (1.0.2)
pagy (3.7.1)
parallel (1.12.1)
parser (2.5.0.5)
ast (~> 2.4.0)
pg (1.1.4)
popper_js (1.14.5)
powerpack (0.1.2)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
public_suffix (4.0.1)
puma (4.3.0)
nio4r (~> 2.0)
rack (2.0.7)
rack-protection (2.0.7)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.2.0)
loofah (~> 2.2, >= 2.2.2)
railties (5.2.3)
actionpack (= 5.2.3)
activesupport (= 5.2.3)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (12.3.2)
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
regexp_parser (1.6.0)
rgeo (2.1.1)
rgeo-geojson (2.1.1)
rgeo (>= 1.0.0)
rubocop (0.64.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.4.0)
ruby-progressbar (1.10.0)
rubyzip (2.0.0)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sassc (2.2.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
railties (>= 4.0.0)
sassc (>= 2.0)
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (3.142.6)
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
semver (1.0.1)
sequel (5.21.0)
sequel-postgis-georuby (0.1.2)
georuby (~> 2.5.2)
sequel (~> 5.0)
sinatra (2.0.7)
mustermann (~> 1.0)
rack (~> 2.0)
rack-protection (= 2.0.7)
tilt (~> 2.0)
sinatra-contrib (2.0.7)
backports (>= 2.8.2)
multi_json
mustermann (~> 1.0)
rack-protection (= 2.0.7)
sinatra (= 2.0.7)
tilt (~> 2.0)
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.1)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
thor (0.19.4)
thread_safe (0.3.6)
tilt (2.0.10)
timecop (0.9.1)
tzinfo (1.2.5)
thread_safe (~> 0.1)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unicode-display_width (1.4.1)
uuidtools (2.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
yajl (0.3.4)
yajl-ruby (1.4.1)

PLATFORMS
ruby

DEPENDENCIES
awesome_print
babosa
better_errors
bootstrap (~> 4.3)
byebug
capybara
database_cleaner
dotenv (~> 2.6)
erubis
event_sourcery
event_sourcery-postgres
foreman
hashie
json_expressions
jsonapi-rb
minitest
minitest-have_tag
nokogiri
nominatim!
offline_geocoder!
open-location-code
opening_hours_converter!
pagy
pry
puma
rack-test
rake
rgeo-geojson
rubocop
sass
selenium-webdriver
semver
sequel-postgis-georuby
sinatra
sinatra-contrib
sprockets
timecop
uglifier
uuidtools
yajl
yajl-ruby

BUNDLED WITH
1.17.3

+ 21
- 0
LICENSE View File

@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2018 placebazaar

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 58
- 0
Makefile View File

@@ -0,0 +1,58 @@
CMD_PREFIX=bundle exec
CONTAINER_NAME=hours_development
TEST_FILES_PATTERN ?= **/*_test.rb

# You want latexmk to *always* run, because make does not have all the info.
# Also, include non-file targets in .PHONY so they are run regardless of any
# file of the given name existing.
.PHONY: all test lint clean setup ruby packages preprocess run

# The first rule in a Makefile is the one executed by default ("make"). It
# should always be the "all" rule, so that "make" and "make all" are identical.
all: test lint
db:
make _docker-install || make _docker-start
make _wait
make _db-setup

# CUSTOM BUILD RULES
test: export APP_ENV=test
test:
$(CMD_PREFIX) ruby -I lib:test:. -e "Dir.glob('$(TEST_FILES_PATTERN)') { |f| require(f) }"
lint:
$(CMD_PREFIX) rubocop

clean:
docker stop hours_development
docker rm hours_development

run:
$(CMD_PREFIX) foreman start

import:
osmium tags-filter /mnt/sda/OSM/netherlands-latest.osm.pbf n/amenity=cafe,bar,restaurant,biergarten,fast_food,food_court,ice_cream,pub n/shop n/amenity=bicycle_parking,parking,parking_entrance --output-format osm | osm2geojson | bin/sink

_docker-start:
@if [ -z $(docker ps --no-trunc | grep $(CONTAINER_NAME)) ]; then docker start $(CONTAINER_NAME); fi

_db-setup:
$(CMD_PREFIX) rake db:create
$(CMD_PREFIX) rake db:event_store
$(CMD_PREFIX) rake db:projections
$(CMD_PREFIX) rake db:seed # TODO: don't run seeds on test

_wait:
sleep 5

##
# Set up the project for building
setup: _ruby _packages _docker-install

_docker-install:
docker run -p 5432:5432 --name $(CONTAINER_NAME) -e POSTGRES_PASSWORD=$(DB_PASSWORD) -d mdillon/postgis

_ruby:
bundle install

_packages:
sudo apt install ruby

+ 2
- 0
Procfile View File

@@ -0,0 +1,2 @@
web: bundle exec rake run_web
processors: bundle exec rake run_processors

+ 101
- 0
README.md View File

@@ -0,0 +1,101 @@
# Hours

RESTful JSON API for opening hours

## Getting Started

TODO: Finish Make Install
TODO: Provide alternative in docker

### Prerequisites

A Linux machine that supports apt, preferably a recent Ubuntu LTS.

`make install` should ensure all dependencies are installed.

For machines that do not support Make, consider getting another OS, if
anything, as a virtual machine.

For machines that do not have apt, we are open to help on making the
`Makefile` more portable. Pull requests, suggestions and help is
welcome. The ideal situation would be where all common POSIX compliant
systems, will be able to run the Makefile and install dependencies.

### Installing

Install all dependencies with
```bash
make install
```
## Running the tests

Running the tests with

```bash
make test
```

### Integration tests

*Integration tests*, or *end to end* tests, are tests that run
expectations on the full application. It sets up, seeds and connects to
a database and then makes requests through the REST HTTP interface.

These are slow, and will, nor cannot, cover all edge-cases, paths and
exceptions. They cover the happy path, common situations and important
features.

Currently ran inside the entire test-suite. If the suite grows too large,
we will extract these and make them runnable separately.

### Unit tests

*The unit tests* isolate a *module* and test that in isolation.
Unfortunately, JavaScript, nor node, nor its ecosystem of packages are
very clean and test-oriented. Many features, packages or libraries will
inject their behaviour in the global namespace, making it very hard,
sometimes impossible to mock or stub.

There are quite some tests, but not near as much as I would like.

## Deployment

Deploying depends on your environment. Anywhere where NodeJS is
supported will probably run this software.

TODO: describe what config to change in order to deploy to your servers.

```bash
make deploy
```

## Built With

* [NodeJS](https://nodejs.org) - NodeJS
* [express](https://expressjs.com/) - Express web framework
* [open street map](https://openstreetmap.org) - Data source
* [imposm](https://imposm.org/) - imposm3 to import Open Street Map data
into a postgresql database.

## Contributing

TODO: introduce CONTRIBUTING.md

## Versioning

We use [SemVer](http://semver.org/) for versioning. For the versions
available, see the [release-tags on this
repository](https://git.webschuur.com/placebazaar/hours/tags?utf8=%E2%9C%93&search=v)

## Authors

* **Bèr Kessels**

## License

This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details

## Acknowledgments

TODO: describe YoHours, openstreetmap, imposm, opening_hours etc.


+ 89
- 0
Rakefile View File

@@ -0,0 +1,89 @@
# frozen_string_literal: true

$LOAD_PATH.unshift '.'

task :environment do
require 'lib/hours'
end

task :sequel do
require 'dotenv'
Dotenv.load(".env.#{ENV['APP_ENV']}", '.env')

require 'sequel'
require 'sequel-postgis-georuby'
@pg_url = "postgres://#{ENV['DB_USER']}:#{ENV['DB_PASSWORD']}@"\
"#{ENV['DB_HOST']}:#{ENV['DB_PORT']}"
end

desc 'Setup Event Stream Processors'
task setup_processors: :environment do
Hours::Projections::Places::Projector.new.setup
end

desc 'Run Event Stream Processors'
task run_processors: :environment do
# Need to disconnect before starting the processors so
# that the forked processes have their own connection / fork safety.
Hours.projections_database.disconnect

esps = [
Hours::Projections::Places::Projector.new
]

# The ESPRunner will fork child processes for each of the ESPs passed to it.
EventSourcery::EventProcessing::ESPRunner.new(
event_processors: esps,
event_source: Hours.event_source
).start!
end

desc 'Run webserver'
task run_web: :environment do
sh %(bundle exec rackup -p #{ENV['PORT'] || 9292})
end

namespace :db do
task create: :sequel do
pg_db = Sequel.connect("#{@pg_url}/postgres")
begin
pg_db.run("CREATE DATABASE #{ENV['DB_NAME']}")
pg_db.disconnect
app_db = Sequel.connect("#{@pg_url}/#{ENV['DB_NAME']}")
app_db.run('CREATE EXTENSION postgis')
app_db.run('CREATE EXTENSION hstore')
rescue Sequel::DatabaseError => e
raise unless e.message.include?(
"database \"#{ENV['DB_NAME']}\" already exists"
)

warn 'database already exists'
end
end

desc 'Re Create Event Store datatabase and tables'
task event_store: :environment do
database = EventSourcery::Postgres.config.event_store_database
EventSourcery::Postgres::Schema.create_event_store(db: database)
end

desc 'Re Create Projections database and tables'
task projections: :environment do
Hours::Projections::Places::Projector.new.setup
end
end

namespace :db do
task seed: :environment do
require_relative 'test/support/data_helpers.rb'
Dir['./test/support/workflows/*.rb'].each { |file| require file }

include DataHelpers
include Workflows::AddPlace

events.each do |event|
puts event
projector.process(event)
end
end
end

+ 80
- 0
app/aggregates/place.rb View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true

require_relative './place/place_id.rb'
require_relative './place/region.rb'

module Hours
module Aggregates
##
# A +place+ is one of the core elements in the OpenStreetMap data model. It
# consists of a single point in space defined by its latitude, longitude
# and place id.
class Place
include EventSourcery::AggregateRoot

UUID_PLACE_NAMESPACE = UUIDTools::UUID.parse(
'99586308-6dcd-489a-a9dd-9c59cf3feb59'
)

def self.aggregate_id_for(payload)
UUIDTools::UUID.sha1_create(UUID_PLACE_NAMESPACE,
PlaceId.new(payload).id)
end

attr_writer :place_id_builder, :region_builder
attr_reader :place_id, :region_slug

# These apply methods are the hook that this aggregate uses to update
# its internal state from events.
apply PlaceAdded do |event|
@aggregate_id = event.aggregate_id
end

def add(payload)
raise DuplicateError, "Place #{id.inspect} already exists" if added?

@payload = Hashie.symbolize_keys(payload)
@place_id = place_id_builder.id
@region_slug = region_builder.region_slug

apply_event(PlaceAdded, aggregate_id: id, body: event_body)
self
end

private

def added?
@aggregate_id
end

def event_body
@payload.merge(
properties: properties.merge(
place_id: place_id,
region_slug: region_slug,
'addr:city': city
)
)
end

def properties
@payload.fetch(:properties, {})
end

def place_id_builder
@place_id_builder || PlaceId.new(@payload)
end

def region_builder
@region_builder || Region.new(@payload)
end

def city
properties[:'addr:city'] || region_builder.city
end
end
end

class DuplicateError < StandardError
end
end

+ 93
- 0
app/aggregates/place/place_id.rb View File

@@ -0,0 +1,93 @@
# frozen_string_literal: true

require 'plus_codes/open_location_code'
require 'rgeo/geo_json'

module Hours
module Aggregates
# Generates a unique, distinguishable ID. Might run into collisions, but it
# is not the task of this model to solve that.
class PlaceId
def initialize(geojson)
if geojson.is_a? String
input = geojson
elsif geojson.is_a? Hash
input = Hashie.stringify_keys(geojson)
end

@place = PlaceBody.new(input)
end

def id
return nil if place.empty?

[
location_code,
place.categories.join(':'),
place.name.downcase.gsub(/[ -:;]/, '')
].join(';')
end

def location_code
coder.encode(place.location[:lat], place.location[:lon], 6)
end

private

attr_reader :place

def coder
@coder ||= PlusCodes::OpenLocationCode.new
end

# Model to wraps a GeoJSON feature object.
# Currently only handles POINTs, so is mostly a model for a POI
class PlaceBody
CATALOG_FILE = ::Hours.base_path.join('config', 'poi_catalog.json')

def initialize(geojson)
@geom = RGeo::GeoJSON.decode(geojson)
end

def name
properties['name'] || ''
end

def location
{ lat: geometry.y, lon: geometry.x }
end

def categories
opr_catalog.map do |cat, predicament|
value_at_key = properties[predicament['key']]
has_match = predicament['values'].include?(value_at_key)

if value_at_key && has_match
[cat, value_at_key]
else
[]
end
end.flatten.compact
end

def empty?
!@geom
end

private

def properties
@geom&.properties || {}
end

def geometry
@geom&.geometry || OpenStruct.new(x: nil, y: nil)
end

def opr_catalog
JSON.parse(File.read(CATALOG_FILE))
end
end
end
end
end

+ 44
- 0
app/aggregates/place/region.rb View File

@@ -0,0 +1,44 @@
# frozen_string_literal: true

require 'offline_geocoder'
require 'babosa'

module Hours
module Aggregates
# Generates a normalized, distinguishable region string.
class Region
attr_writer :offline_geocoder

def initialize(geojson)
@geojson = Hashie.symbolize_keys(geojson)
end

def region_slug
city.to_slug.normalize.to_s
end

def city
@geojson.fetch(:properties, {})
.fetch(:'addr:city', region_from_geom)
end

private

def lat
@geojson.fetch(:geometry, {}).fetch(:coordinates)[1]
end

def lon
@geojson.fetch(:geometry, {}).fetch(:coordinates)[0]
end

def region_from_geom
offline_geocoder.search(lat, lon)[:name]
end

def offline_geocoder
@offline_geocoder ||= OfflineGeocoder.new
end
end
end
end

+ 1
- 0
app/assets/javascripts/application.js View File

@@ -0,0 +1 @@
//= require "leaflet.js"

+ 13986
- 0
app/assets/javascripts/leaflet-src.esm.js
File diff suppressed because it is too large
View File


+ 1
- 0
app/assets/javascripts/leaflet-src.esm.js.map
File diff suppressed because it is too large
View File


+ 14080
- 0
app/assets/javascripts/leaflet-src.js
File diff suppressed because it is too large
View File


+ 1
- 0
app/assets/javascripts/leaflet-src.js.map
File diff suppressed because it is too large
View File


+ 5
- 0
app/assets/javascripts/leaflet.js
File diff suppressed because it is too large
View File


+ 1
- 0
app/assets/javascripts/leaflet.js.map
File diff suppressed because it is too large
View File


+ 11
- 0
app/assets/stylesheets/application.scss View File

@@ -0,0 +1,11 @@
// Custom bootstrap variables must be set or imported *before* bootstrap.

@import "bootstrap";
@import "leaflet.css";

#map {
height: 400px;
}
#map.full {
height: 800px;
}

+ 640
- 0
app/assets/stylesheets/leaflet.css View File

@@ -0,0 +1,640 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg,
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline: 0;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-container a.leaflet-active {
outline: 2px solid orange;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a,
.leaflet-bar a:hover {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(/images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(/images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path {
background-image: url(/images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.7);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover {
text-decoration: underline;
}
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-scale {
font-size: 11px;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: #fff;
background: rgba(255, 255, 255, 0.5);
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 19px;
line-height: 1.4;
}
.leaflet-popup-content p {
margin: 18px 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
padding: 4px 4px 0 0;
border: none;
text-align: center;
width: 18px;
height: 14px;
font: 16px/14px Tahoma, Verdana, sans-serif;
color: #c3c3c3;
text-decoration: none;
font-weight: bold;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover {
color: #999;
}
.leaflet-popup-scrolled {
overflow: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-popup-tip-container {
margin-top: -1px;
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-clickable {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}

+ 38
- 0
app/commands/add_place_command.rb View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true

module Hours
# A command handler and commands that can be issued against the system.
# These form an interface between the web API and the domain model in
# the aggregate.
class AddPlaceCommand
attr_reader :payload

def self.build(**args)
new(**args).tap(&:validate)
end

def initialize(params)
@params = params
@payload = params.slice(
:type,
:geometry,
:properties
)
end

def aggregate_class
Aggregates::Place
end

def validate
raise ValidationError, 'geometry is blank' unless payload[:geometry]
end

def aggregate_id
@aggregate_id ||= Hours::Aggregates::Place.aggregate_id_for(payload)
end
end

class ValidationError < TypeError
end
end

+ 27
- 0
app/commands/command_handler.rb View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Hours
# A command handler and commands that can be issued against the system.
# These form an interface between the web API and the domain model in
# the aggregate.

# A command handler runs the command.
class CommandHandler
def initialize(repository: Hours.repository)
@repository = repository
end

# Handle loads the aggregate state from the store using the
# repository, defers to the aggregate to execute the command, and
# saves off any newly raised events to the store.
def handle(command)
aggregate = repository.load(command.aggregate_class, command.aggregate_id)
aggregate.add(command.payload)
repository.save(aggregate)
end

private

attr_reader :repository
end
end

+ 5
- 0
app/events/place_added.rb View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true

require 'event_sourcery/event'

PlaceAdded = Class.new(EventSourcery::Event)

+ 5
- 0
app/events/place_proposed.rb View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true

require 'event_sourcery/event'

PlaceProposed = Class.new(EventSourcery::Event)

+ 15
- 0
app/models/base.rb View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Hours
module Models
##
# Base model that can read from a query database
class Base
def initialize(attributes = {})
attributes.each do |key, value|
send("#{key}=", value)
end
end
end
end
end

+ 36
- 0
app/models/base_collection.rb View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Hours
module Models
##
# Base model that holds a list of other models
class BaseCollection < Delegator
include Enumerable

attr_accessor :paginator

def __getobj__
@dataset
end

def initialize(dataset, paginator = nil)
@dataset = dataset
@paginator = paginator
end

def to_a
@dataset.map { |row| self.class::ITEM_CLASS.new(row) }
end

def each
@dataset.each do |row|
if row
yield self.class::ITEM_CLASS.new(row)
else
yield self.class::NULLITEM_CLASS.new
end
end
end
end
end
end

+ 103
- 0
app/models/place.rb View File

@@ -0,0 +1,103 @@
# frozen_string_literal: true

require 'opening_hours_converter'

require_relative 'base.rb'

module Hours
module Models
##
# A Place is a readonly model for querying the projection of "places".
# * Readonly: it is not enforced on model or ORM level, but should be
# enforced in the database-server and user setup. RO only means that
# this model is only tested and optimized against reading.
class Place < Base
ITERATOR = OpeningHoursConverter::Iterator.new
PARSER = OpeningHoursConverter::OpeningHoursParser.new

attr_accessor :id, :location, :place_id, :name,
:region_slug, :distance
attr_writer :address, :opening_hours

def lat
location.lat
end

def lon
location.lon
end

def status
return :unknown if opening_hours&.empty?

ITERATOR.is_opened?(opening_hours) ? :open : :closed
rescue OpeningHoursConverter::ParseError
:unknown
end

def open_this_week
date_ranges = PARSER.parse(opening_hours)
get_intervals_as_week(date_ranges, Date.today)
rescue OpeningHoursConverter::ParseError
{}
end

def address
return unless raw_address

OpenStruct.new(Sequel::Postgres::HStore.parse(raw_address).to_hash)
end

def region
address&.city
end

def raw_address
@address
end

def opening_hours
@opening_hours || ''
end

private

def get_intervals_as_week(date_ranges, date_in_week)
from = beginning_of_week(date_in_week)
to = end_of_week(date_in_week)

as_week = european_week.dup
intervals_for(date_ranges, from, to).each do |interval|
as_week[interval[:start].wday] << interval
end

as_week
end

def intervals_for(date_ranges, from, to)
ITERATOR.get_time_iterator(date_ranges).select do |interval|
interval[:start] >= from && interval[:end] <= to
end
end

# Sets up an empty 7-day week template
def european_week
week = {}
[1, 2, 3, 4, 5, 6, 0].each do |wday|
week[wday] = []
end
week
end

def beginning_of_week(date)
days_to_monday = date.wday != 0 ? date.wday - 1 : 6
(date - days_to_monday).to_time
end

def end_of_week(date)
days_to_next_monday = date.wday != 0 ? 8 - date.wday : 1
(date + days_to_next_monday).to_time - 1
end
end
end
end

+ 28
- 0
app/models/place_index.rb View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true

require_relative 'base_collection.rb'

module Hours
module Models
# Utility class to hold a place that is empty
class NullPlace
def region
''
end
end

##
# A collection of places
class PlaceIndex < BaseCollection
ITEM_CLASS = ::Hours::Models::Place
NULLITEM_CLASS = ::Hours::Models::NullPlace

attr_accessor :centerpoint

def initialize(dataset, paginator = nil, centerpoint = nil)
super(dataset, paginator)
@centerpoint = centerpoint
end
end
end
end

+ 23
- 0
app/models/region.rb View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true

require_relative 'base.rb'
require_relative 'place_index.rb'

module Hours
module Models
##
# Region is a collection of Places belonging to one region.
# It is, therefore, a decorator for a list of places.
class Region < PlaceIndex
def initialize(dataset,
paginator = nil, centerpoint = nil, region_slug = '')
@region_slug = region_slug
super(dataset, paginator, centerpoint)
end

def name
@region_slug.capitalize
end
end
end
end

+ 61
- 0
app/projections/places.rb View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true

require Hours.base_path.join('app', 'events', 'place_added.rb')

module Hours
module Projections
module Places
##
# Handles the Places Projection
class Projector
include EventSourcery::Postgres::Projector

projector_name :places

# Database tables that form the projection.
table :query_places do
column :id, 'UUID NOT NULL', primary_key: true
column :place_id, :varchar, null: false, size: 255
column :region_slug, :varchar, null: false, size: 255
column :name, :text
column :opening_hours, :text
column :location, 'geography(POINT)'
column :address, :hstore
index :place_id, unique: true
index :region_slug, unique: false
end

# Event handlers that update the projection in response to different
# events from the store.
project PlaceAdded do |event|
geometry = event.body['geometry']
properties = event.body['properties']

unless table.where(place_id: properties['place_id']).any?
table.insert(
id: event.aggregate_id,
place_id: properties['place_id'],
name: properties['name'],
opening_hours: properties['opening_hours'],
location: GeoRuby::SimpleFeatures::Point.from_coordinates(
geometry['coordinates']
),
region_slug: properties['region_slug'],
address: Sequel.hstore(
street: properties['addr:street'],
housenumber: properties['addr:housenumber'],
postcode: properties['addr:postcode'],
city: properties['addr:city'],
addr_country_code: properties['addr:country']
)
)
end
end

def exists?(place_id)
table.where(place_id: place_id).any?
end
end
end
end
end

+ 93
- 0
app/projections/query.rb View File

@@ -0,0 +1,93 @@
# frozen_string_literal: true

require 'pagy'

module Hours
module Projections
##
# Common behaviour for all query objects
class BaseQuery
DEFAULT_DATASET = Hours.projections_database[:query_places]
attr_reader :dataset, :page

include Pagy::Backend

def initialize(dataset)
@dataset = dataset
@page = nil
end

def self.build
new(DEFAULT_DATASET)
end

def pagy_get_vars(collection, vars)
vars[:count] = collection.count
vars[:page] = page
vars
end
end

# Query handler that queries the projection table for a single node
class PlaceQuery < BaseQuery
def handle(id)
result = dataset[id: id]
Hours::Models::Place.new(result) if result
end

def uuid_for(place_id)
dataset.where(place_id: place_id).get(:id)
end
end

# Query handler that queries the projection table for all nodes
class PlacesQuery < BaseQuery
def handle(page = 1)
@page = page
pagy, records = pagy(dataset)
Hours::Models::PlaceIndex.new(records, pagy)
end
end

# Query handler that builds a list of places in a Region
class RegionQuery < BaseQuery
def handle(region_slug, page = 1)
@page = page
@region_slug = region_slug
raise ArgumentError if region_slug.nil? || region_slug.empty?

distance = Sequel.lit('ST_Distance(location, ?) as distance', center)
pagy, records = pagy(
dataset.select_append(distance)
.where(region_slug: region_slug)
.order(:distance)
)
Hours::Models::Region.new(records, pagy, center, region_slug)
end

private

def center
return @center if @center
return unless region_name

centroid = OfflineGeocoder.new.search(
name: region_name,
cc: 'NL'
)
@center = GeoRuby::SimpleFeatures::Point.from_xy(
centroid[:lon], centroid[:lat]
)
end

def region_name
return @region_name if @region_name

first_place = dataset.select(:address).first(region_slug: @region_slug)
return unless first_place

@region_name = Hours::Models::Place.new(first_place).region
end
end
end
end

+ 42
- 0
app/serializers/place.rb View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true

require 'jsonapi/serializable'

module Hours
module Serializers
##
# Represent a Place as JSON
class Place < JSONAPI::Serializable::Resource
type 'place'

attributes :name, :lat, :lon, :status, :open_this_week, :place_id
id { @object.id }

attribute :raw_opening_hours do
@object.opening_hours
end

attribute :address do
(@object&.address.to_h || {}).slice(:postcode,
:city,
:housenumber,
:street)
end

link :self do
# TODO: move into an URL-helper
"/places/#{@object.id}"
end

meta do
{ copyright: 'OpenStreetMap-contributors' }
end

belongs_to :region do
link(:self) do
"/in/#{@object.region_slug}"
end
end
end
end
end

+ 33
- 0
app/views/layout.erb View File

@@ -0,0 +1,33 @@
<!doctype html>
<html lang="nl">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<link rel="stylesheet" href="/assets/application.css" type="text/css" media="all">
<script src="/assets/application.js"></script>

<title>Openingstijden</title>
</head>
<body>
<div class="container-fluid">
<h1 class="title">Openingstijden</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Openingstijden</a></li>
<li class="breadcrumb-item"><a href="/in/<%= content_for(:region_slug) %>">
in <%= content_for(:region_name) %>
</a></li>
<li class="breadcrumb-item active" aria-current="page">
<%= content_for(:place_name) %>
</li>
</ol>
</nav>