├── storage
└── .keep
├── vendor
└── .keep
├── lib
├── assets
│ └── .keep
├── tasks
│ ├── .keep
│ ├── database
│ │ ├── clear.rake
│ │ └── seed.rake
│ └── auto_annotate_models.rake
└── code_red
│ └── version.rb
├── spec
├── support
│ ├── .keep
│ ├── webmock.rb
│ ├── factory_bot.rb
│ ├── shoulda_matchers.rb
│ └── redis.rb
├── graph
│ ├── node_spec.rb
│ ├── edge_spec.rb
│ ├── graph_spec.rb
│ ├── task_spec.rb
│ ├── concerns
│ │ ├── timestamps_spec.rb
│ │ ├── associations_spec.rb
│ │ ├── relationships_spec.rb
│ │ └── persistence
│ │ │ ├── node_spec.rb
│ │ │ └── edge_spec.rb
│ ├── renderer_spec.rb
│ └── dsl_spec.rb
├── factories
│ ├── users.rb
│ ├── projects.rb
│ └── graph.rb
├── models
│ ├── project_spec.rb
│ └── user_spec.rb
├── rails_helper.rb
└── spec_helper.rb
├── tmp
└── pids
│ └── .keep
├── .browserslistrc
├── app
├── models
│ ├── concerns
│ │ └── .keep
│ ├── application_record.rb
│ ├── user.rb
│ └── project.rb
├── assets
│ └── config
│ │ └── manifest.js
├── controllers
│ ├── concerns
│ │ └── .keep
│ ├── application_controller.rb
│ ├── projects_controller.rb
│ ├── calendar_controller.rb
│ ├── tasks_controller.rb
│ └── relationships_controller.rb
├── views
│ ├── layouts
│ │ ├── mailer.text.erb
│ │ ├── mailer.html.erb
│ │ └── application.html.erb
│ ├── tasks
│ │ ├── index.json.erb
│ │ ├── edit.html.erb
│ │ ├── new.html.erb
│ │ ├── _modal_form.html.erb
│ │ ├── _task.html.erb
│ │ └── index.html.erb
│ ├── components
│ │ └── _notifications.html.erb
│ └── calendar
│ │ └── show.html.erb
├── javascript
│ ├── stylesheets
│ │ ├── base.scss
│ │ ├── utilities.scss
│ │ ├── components.scss
│ │ ├── notifications.scss
│ │ ├── rich_text.scss
│ │ └── graph.scss
│ ├── images
│ │ ├── logo.png
│ │ └── splash.jpg
│ ├── controllers
│ │ ├── task_modal_controller.js
│ │ ├── index.js
│ │ ├── rich_text_controller.js
│ │ ├── task_dropdown_controller.js
│ │ └── graph_controller.js
│ ├── channels
│ │ ├── index.js
│ │ └── consumer.js
│ ├── packs
│ │ ├── application.js
│ │ └── application.scss
│ └── graph
│ │ └── settings.js
├── helpers
│ ├── application_helper.rb
│ ├── heroicon_helper.rb
│ └── task_helper.rb
├── channels
│ └── application_cable
│ │ ├── channel.rb
│ │ └── connection.rb
├── mailers
│ └── application_mailer.rb
├── jobs
│ └── application_job.rb
└── graph
│ ├── concerns
│ ├── timestamps.rb
│ ├── associations.rb
│ ├── clause.rb
│ ├── relationships.rb
│ ├── rich_text.rb
│ └── persistence
│ │ ├── node.rb
│ │ └── edge.rb
│ ├── graph.rb
│ ├── node.rb
│ ├── edge.rb
│ ├── task.rb
│ ├── renderer.rb
│ └── dsl.rb
├── db
├── seeds
│ └── development
│ │ ├── .keep
│ │ ├── 02_projects.rb
│ │ ├── 01_users.rb
│ │ └── 03_tasks.rb
├── migrate
│ ├── 20210417142434_enable_uuid.rb
│ ├── 20210427231832_create_users.rb
│ ├── 20210511164733_create_projects.rb
│ └── 20210504172530_create_active_storage_tables.active_storage.rb
└── schema.rb
├── public
├── apple-touch-icon.png
├── apple-touch-icon-precomposed.png
├── favicon.ico
├── robots.txt
├── 500.html
├── 422.html
└── 404.html
├── .rspec
├── logo.png
├── screenshot0.png
├── screenshot1.png
├── screenshot2.png
├── screenshot3.png
├── architecture.png
├── images
└── app_preview_image.png
├── config
├── webpack
│ ├── environment.js
│ ├── test.js
│ ├── production.js
│ └── development.js
├── spring.rb
├── initializers
│ ├── heroicon.rb
│ ├── mime_types.rb
│ ├── application_controller_renderer.rb
│ ├── cookies_serializer.rb
│ ├── filter_parameter_logging.rb
│ ├── permissions_policy.rb
│ ├── assets.rb
│ ├── wrap_parameters.rb
│ ├── backtrace_silencers.rb
│ ├── inflections.rb
│ └── content_security_policy.rb
├── environment.rb
├── boot.rb
├── cable.yml
├── credentials.yml.enc
├── locales
│ └── en.yml
├── storage.yml
├── application.rb
├── puma.rb
├── webpacker.yml
├── environments
│ ├── test.rb
│ ├── development.rb
│ └── production.rb
├── database.yml
└── routes.rb
├── .yarnrc
├── bin
├── rake
├── rails
├── favicon
├── reset
├── webpack
├── webpack-dev-server
├── spring
├── yarn
├── update
├── setup
└── bundle
├── terraform
├── terraform.tfvars.example
├── versions.tf
├── README.md
├── variables.tf
├── .gitignore
├── .terraform.lock.hcl
├── default.tmpl.yml
└── main.tf
├── config.ru
├── tailwind.config.js
├── .gitattributes
├── Rakefile
├── postcss.config.js
├── package.json
├── .development.env
├── docker-compose.yml
├── Dockerfile.orig
├── LICENSE
├── Dockerfile
├── marketplace.json
├── .overcommit.yml
├── .gitignore
├── .dockerignore
├── .rubocop.yml
├── babel.config.js
├── .github
└── workflows
│ ├── cd.yml
│ └── ci.yml
├── Dockerfile.prod
├── Gemfile
└── ops
└── docker-compose.yml
/storage/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/vendor/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/tasks/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/support/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tmp/pids/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.browserslistrc:
--------------------------------------------------------------------------------
1 | defaults
2 |
--------------------------------------------------------------------------------
/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/db/seeds/development/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --require rails_helper
3 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/app/javascript/stylesheets/base.scss:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/base';
2 |
--------------------------------------------------------------------------------
/app/javascript/stylesheets/utilities.scss:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/utilities';
2 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/logo.png
--------------------------------------------------------------------------------
/app/javascript/stylesheets/components.scss:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/components';
2 |
--------------------------------------------------------------------------------
/app/views/tasks/index.json.erb:
--------------------------------------------------------------------------------
1 | <%= ::Renderer.new(project).to_h.to_json.html_safe %>
2 |
--------------------------------------------------------------------------------
/screenshot0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/screenshot0.png
--------------------------------------------------------------------------------
/screenshot1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/screenshot1.png
--------------------------------------------------------------------------------
/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/screenshot2.png
--------------------------------------------------------------------------------
/screenshot3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/screenshot3.png
--------------------------------------------------------------------------------
/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/architecture.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/public/favicon.ico
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ApplicationHelper
4 | end
5 |
--------------------------------------------------------------------------------
/images/app_preview_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/images/app_preview_image.png
--------------------------------------------------------------------------------
/app/javascript/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/app/javascript/images/logo.png
--------------------------------------------------------------------------------
/config/webpack/environment.js:
--------------------------------------------------------------------------------
1 | const { environment } = require('@rails/webpacker')
2 |
3 | module.exports = environment
4 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 |
--------------------------------------------------------------------------------
/app/javascript/images/splash.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/redis-developer/code-red/master/app/javascript/images/splash.jpg
--------------------------------------------------------------------------------
/.yarnrc:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | lastUpdateCheck 1619259215962
6 |
--------------------------------------------------------------------------------
/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationController < ActionController::Base; end
4 |
--------------------------------------------------------------------------------
/app/helpers/heroicon_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module HeroiconHelper
4 | include Heroicon::Engine.helpers
5 | end
6 |
--------------------------------------------------------------------------------
/spec/support/webmock.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | WebMock.disable_net_connect!(allow: ENV.fetch("WEBMOCK_ALLOW_HOST", "localhost"))
4 |
--------------------------------------------------------------------------------
/spec/support/factory_bot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.configure do |config|
4 | config.include FactoryBot::Syntax::Methods
5 | end
6 |
--------------------------------------------------------------------------------
/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationRecord < ActiveRecord::Base
4 | self.abstract_class = true
5 | end
6 |
--------------------------------------------------------------------------------
/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | load File.expand_path("spring", __dir__)
3 | require_relative "../config/boot"
4 | require "rake"
5 | Rake.application.run
6 |
--------------------------------------------------------------------------------
/terraform/terraform.tfvars.example:
--------------------------------------------------------------------------------
1 | ##
2 | # Providers
3 | #
4 | hcloud_token = ""
5 | gandi_key = ""
6 |
7 | ##
8 | # Services
9 | #
10 | passwd = ""
11 |
--------------------------------------------------------------------------------
/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ApplicationCable
4 | class Channel < ActionCable::Channel::Base
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/config/spring.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Spring.watch(
4 | ".ruby-version",
5 | ".rbenv-vars",
6 | "tmp/restart.txt",
7 | "tmp/caching-dev.txt",
8 | )
9 |
--------------------------------------------------------------------------------
/config/initializers/heroicon.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Heroicon.configure do |config|
4 | config.variant = :outline # Options are :solid and :outline
5 | end
6 |
--------------------------------------------------------------------------------
/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ApplicationCable
4 | class Connection < ActionCable::Connection::Base
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationMailer < ActionMailer::Base
4 | default from: "from@example.com"
5 | layout "mailer"
6 | end
7 |
--------------------------------------------------------------------------------
/config/webpack/test.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'
2 |
3 | const environment = require('./environment')
4 |
5 | module.exports = environment.toWebpackConfig()
6 |
--------------------------------------------------------------------------------
/config/webpack/production.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production'
2 |
3 | const environment = require('./environment')
4 |
5 | module.exports = environment.toWebpackConfig()
6 |
--------------------------------------------------------------------------------
/db/migrate/20210417142434_enable_uuid.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class EnableUUID < ActiveRecord::Migration[6.0]
4 | def change
5 | enable_extension "pgcrypto"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/config/environment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Load the Rails application.
4 | require_relative "application"
5 |
6 | # Initialize the Rails application.
7 | Rails.application.initialize!
8 |
--------------------------------------------------------------------------------
/config/webpack/development.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'
2 |
3 | const environment = require('./environment')
4 |
5 | module.exports = environment.toWebpackConfig()
6 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | load File.expand_path("spring", __dir__)
3 | APP_PATH = File.expand_path('../config/application', __dir__)
4 | require_relative "../config/boot"
5 | require "rails/commands"
6 |
--------------------------------------------------------------------------------
/spec/graph/node_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Node, type: :model do
4 | subject(:node) { build(:node) }
5 |
6 | it { is_expected.to validate_presence_of :graph }
7 | end
8 |
--------------------------------------------------------------------------------
/app/javascript/controllers/task_modal_controller.js:
--------------------------------------------------------------------------------
1 | import { Modal } from 'tailwindcss-stimulus-components';
2 |
3 | export default class extends Modal {
4 | connect() {
5 | super.connect()
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/controllers/projects_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ProjectsController < ApplicationController
4 | def project
5 | @project ||= Project.find(params[:project_id])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/factories/users.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | FactoryBot.define do
4 | factory :user, class: "User" do
5 | name { FFaker::Name.name }
6 | email { FFaker::Internet.email(name) }
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is used by Rack-based servers to start the application.
4 |
5 | require_relative "config/environment"
6 |
7 | run Rails.application
8 | Rails.application.load_server
9 |
--------------------------------------------------------------------------------
/spec/support/shoulda_matchers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Shoulda::Matchers.configure do |config|
4 | config.integrate do |with|
5 | with.test_framework :rspec
6 | with.library :rails
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | purge: [],
3 | darkMode: false, // or 'media' or 'class'
4 | theme: {
5 | extend: {},
6 | },
7 | variants: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
--------------------------------------------------------------------------------
/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # Be sure to restart your server when you modify this file.
3 |
4 | # Add new mime types for use in respond_to blocks:
5 | # Mime::Type.register "text/richtext", :rtf
6 |
--------------------------------------------------------------------------------
/spec/factories/projects.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | FactoryBot.define do
4 | factory :project, class: "Project" do
5 | name { FFaker::Name.name }
6 | description { FFaker::Lorem.sentence }
7 |
8 | user
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/app/javascript/channels/index.js:
--------------------------------------------------------------------------------
1 | // Load all the channels within this directory and all subdirectories.
2 | // Channel files must be named *_channel.js.
3 |
4 | const channels = require.context('.', true, /_channel\.js$/)
5 | channels.keys().forEach(channels)
6 |
--------------------------------------------------------------------------------
/config/boot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
4 |
5 | require "bundler/setup" # Set up gems listed in the Gemfile.
6 | require "bootsnap/setup" # Speed up boot time by caching expensive operations.
7 |
--------------------------------------------------------------------------------
/spec/models/project_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Project do
4 | subject(:project) { build(:project) }
5 |
6 | it { is_expected.to belong_to(:user).optional }
7 |
8 | it { is_expected.to validate_presence_of :name }
9 | end
10 |
--------------------------------------------------------------------------------
/app/javascript/packs/application.js:
--------------------------------------------------------------------------------
1 | window.Rails = require('@rails/ujs')
2 | import 'controllers'
3 |
4 | import './application.scss'
5 | import '../images/logo.png'
6 |
7 | import * as cola from "webcola/dist"
8 |
9 | window.cola = cola
10 |
11 | Rails.start()
12 |
--------------------------------------------------------------------------------
/app/views/tasks/edit.html.erb:
--------------------------------------------------------------------------------
1 | <%= turbo_frame_tag "task" do %>
2 |
3 |
4 | <%= render partial: "tasks/form", locals: { project: project, task: task } %>
5 |
6 |
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/app/views/tasks/new.html.erb:
--------------------------------------------------------------------------------
1 | <%= turbo_frame_tag "task" do %>
2 |
3 |
4 | <%= render partial: "tasks/form", locals: { project: project, task: task } %>
5 |
6 |
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files.
2 |
3 | # Mark the database schema as having been generated.
4 | db/schema.rb linguist-generated
5 |
6 |
7 | # Mark any vendored files as having been vendored.
8 | vendor/* linguist-vendored
9 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Add your own tasks in files placed in lib/tasks ending in .rake,
4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
5 |
6 | require_relative "config/application"
7 |
8 | Rails.application.load_tasks
9 |
--------------------------------------------------------------------------------
/spec/support/redis.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.configure do |config|
4 | config.before do
5 | # Change database number
6 | ENV["REDIS_URL"] = "redis://redis:6379/15"
7 |
8 | # Clear database
9 | Redis.new(url: ENV["REDIS_URL"]).flushdb
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/models/user_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe User do
4 | subject(:user) { build(:user) }
5 |
6 | it { is_expected.to have_many :projects }
7 |
8 | it { is_expected.to validate_presence_of :email }
9 | it { is_expected.to validate_presence_of :name }
10 | end
11 |
--------------------------------------------------------------------------------
/app/javascript/channels/consumer.js:
--------------------------------------------------------------------------------
1 | // Action Cable provides the framework to deal with WebSockets in Rails.
2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command.
3 |
4 | import { createConsumer } from '@rails/actioncable'
5 |
6 | export default createConsumer()
7 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-import'),
4 | require('tailwindcss'),
5 | require('postcss-flexbugs-fixes'),
6 | require('postcss-preset-env')({
7 | autoprefixer: {
8 | flexbox: 'no-2009'
9 | },
10 | stage: 3
11 | })
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # Be sure to restart your server when you modify this file.
3 |
4 | # ActiveSupport::Reloader.to_prepare do
5 | # ApplicationController.renderer.defaults.merge!(
6 | # http_host: 'example.org',
7 | # https: false
8 | # )
9 | # end
10 |
--------------------------------------------------------------------------------
/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Specify a serializer for the signed and encrypted cookie jars.
6 | # Valid options are :json, :marshal, and :hybrid.
7 | Rails.application.config.action_dispatch.cookies_serializer = :json
8 |
--------------------------------------------------------------------------------
/db/migrate/20210427231832_create_users.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateUsers < ActiveRecord::Migration[6.1]
4 | def change
5 | create_table :users, id: :uuid do |t|
6 | t.string :email, null: false, index: true
7 | t.string :name, null: false
8 |
9 | t.timestamps
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/spec/graph/edge_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Edge, type: :model do
4 | subject(:edge) { build(:edge) }
5 |
6 | it { is_expected.to validate_presence_of :graph }
7 |
8 | it { is_expected.to validate_presence_of :type }
9 | it { is_expected.to validate_inclusion_of(:type).in_array Edge::TYPES }
10 | end
11 |
--------------------------------------------------------------------------------
/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: redis
3 | url: <%= ENV.fetch("REDIS_URL") { "redis://redis:6379/1" } %>
4 | channel_prefix: code_red_development
5 |
6 | test:
7 | adapter: test
8 |
9 | production:
10 | adapter: redis
11 | url: <%= ENV.fetch("REDIS_URL") { "redis://redis:6379/1" } %>
12 | channel_prefix: code_red_production
13 |
--------------------------------------------------------------------------------
/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationJob < ActiveJob::Base
4 | # Automatically retry jobs that encountered a deadlock
5 | # retry_on ActiveRecord::Deadlocked
6 |
7 | # Most jobs are safe to ignore if the underlying records are no longer available
8 | # discard_on ActiveJob::DeserializationError
9 | end
10 |
--------------------------------------------------------------------------------
/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Configure sensitive parameters which will be filtered from the log file.
6 | Rails.application.config.filter_parameters += [
7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn,
8 | ]
9 |
--------------------------------------------------------------------------------
/terraform/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | gandi = {
4 | source = "psychopenguin/gandi"
5 | version = "2.0.0-rc3"
6 | }
7 | hcloud = {
8 | source = "hetznercloud/hcloud"
9 | }
10 | template = {
11 | source = "hashicorp/template"
12 | }
13 | }
14 | required_version = ">= 0.13"
15 | }
16 |
--------------------------------------------------------------------------------
/bin/favicon:
--------------------------------------------------------------------------------
1 | #!/usr/bin/sh
2 |
3 | SOURCE="app/javascript/images/logo.png"
4 | FAVICON="public/favicon.ico"
5 |
6 | convert ${SOURCE} -resize 16x16 /tmp/favicon-16.png
7 | convert ${SOURCE} -resize 32x32 /tmp/favicon-32.png
8 | convert ${SOURCE} -resize 64x64 /tmp/favicon-64.png
9 | convert /tmp/favicon-16.png /tmp/favicon-32.png /tmp/favicon-64.png ${FAVICON}
10 |
--------------------------------------------------------------------------------
/lib/code_red/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module CodeRed
4 | module Version
5 | MAJOR = 0
6 | MINOR = 7
7 | PATCH = 0
8 | PRE = nil
9 |
10 | VERSION = [MAJOR, MINOR, PATCH].compact.join(".")
11 |
12 | STRING = [VERSION, PRE].compact.join("-")
13 | end
14 |
15 | VERSION = CodeRed::Version::STRING
16 | end
17 |
--------------------------------------------------------------------------------
/terraform/README.md:
--------------------------------------------------------------------------------
1 | # Terraform setup
2 |
3 | This directory contains the infrastructure setup in Terraform format.
4 | The servers are hosted on [Hetzner Cloud](https://www.hetzner.com/).
5 |
6 | ## Setup
7 |
8 | ```
9 | # Set variables
10 | cp terraform.tfvars.example terraform.tfvars
11 | nano terraform.tfvars
12 |
13 | # Initialize Terraform
14 | terraform init
15 | ```
16 |
--------------------------------------------------------------------------------
/lib/tasks/database/clear.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | namespace :database do
4 | desc "Clear database (Redis only)"
5 | task clear: :environment do
6 | # Clear Redis
7 | Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/1")).flushdb
8 |
9 | # Clear PostgreSQL
10 | %w(User Project).each { |model| model.constantize.delete_all }
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/seeds/development/02_projects.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | puts "== Creating projects =="
4 |
5 | return if Project.any?
6 |
7 | Project
8 | .create_with(
9 | name: "Redis Hackathon",
10 | description: "Experience the speed, simplicity and fun of Redis!",
11 | user: User.all.sample,
12 | )
13 | .find_or_create_by!(id: "055616f0-a130-42b1-a3fd-81b7c8a3ef1b")
14 |
--------------------------------------------------------------------------------
/app/graph/concerns/timestamps.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Timestamps
4 | extend ActiveSupport::Concern
5 |
6 | included do
7 | attribute :created_at, :datetime
8 | attribute :updated_at, :datetime
9 |
10 | before_save :set_timestamps
11 |
12 | def set_timestamps
13 | self.created_at ||= Time.zone.now
14 | self.updated_at = Time.zone.now
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/spec/graph/graph_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Graph do
4 | subject(:graph) { build(:graph, name: "my_graph") }
5 |
6 | it "has a name" do
7 | expect(graph.name).to eq "my_graph"
8 | end
9 |
10 | describe "#tasks" do
11 | it "returns a list of tasks" do
12 | task = create(:task, graph: graph)
13 |
14 | expect(graph.tasks).to eq [task]
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/javascript/graph/settings.js:
--------------------------------------------------------------------------------
1 | export default {
2 | node: {
3 | width: 175,
4 | height: 75,
5 | radius: 2,
6 | padding: 10,
7 | },
8 | edge: {
9 | length: 200,
10 | margin: 10,
11 | },
12 | icon: {
13 | viewbox: 1200,
14 | },
15 | zoom: {
16 | min: 0.3,
17 | max: 2,
18 | step: 0.3,
19 | },
20 | iterations: {
21 | layout: 40,
22 | structural: 30,
23 | all: 50,
24 | timeout: 3000,
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/config/credentials.yml.enc:
--------------------------------------------------------------------------------
1 | 58UcgfdSxees0agpkCS8QJe2I25Ij/GsYCUm2gNyZi3YWsLexkBOkkIPckrTDBfFUSNLDLeHwfSZQQSZhkwdk+nxxdjz0THlTP9tETBVwL3z3lWuFZaftaghoZe5Yy3iiFvE4h5UIz6bDgDx+JSdf/hd7vMa8v0M8sfXPUwU10zoPfAH70kZ4g9X7b9V3xw/zYqHV3JRr08dkIBaid7k+R5pymNmFyriXJnPKrx9HtIfMYsvRNyk5z6ULI1R2qNdMl7o/fxxxi/TokcHrRGzup5Afw3O2/IvxGEjC/q2IuYnM7Nvu6Wn4/k8Mib4rY7DMRywRzkasUwqwmHPPbxP+L8PAkMc2xuSt2jQfoGOCjihk66zcU12nVS9CY3HqQb1P4j54ZRjRedRqSOj8xBsih8uAkatlmml5m+A--8ryO31xz09Z7nGJQ--U21Xbp2WgsDCZmYhSoQrOg==
--------------------------------------------------------------------------------
/bin/reset:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "rake"
5 |
6 | require_relative "../config/environment"
7 |
8 | Rails.application.load_tasks
9 |
10 | interval = ENV.fetch("RESET_INTERVAL", 0)&.to_i
11 |
12 | return if interval.zero?
13 |
14 | loop do
15 | puts "[#{DateTime.current.iso8601}] Resetting database"
16 | Rake::Task["database:clear"].invoke
17 | Rake::Task["database:seed"].invoke
18 |
19 | sleep interval.minutes
20 | end
21 |
--------------------------------------------------------------------------------
/db/seeds/development/01_users.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | puts "== Creating users =="
4 |
5 | return if User.any?
6 |
7 | User.create!(name: "Darth Vader", email: "darthvader@example.com")
8 | User.create!(name: "Luke Skywalker", email: "lukeskywalker@example.com")
9 | User.create!(name: "Obi-Wan Kenobi", email: "obiwankenobi@example.com")
10 | User.create!(name: "Han Solo", email: "hansolo@example.com")
11 | User.create!(name: "Chewbacca", email: "chewbacca@example.com")
12 |
--------------------------------------------------------------------------------
/spec/graph/task_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Task, type: :model do
4 | subject(:task) { build(:task) }
5 |
6 | it { is_expected.to validate_presence_of :title }
7 |
8 | it { is_expected.to validate_presence_of :status }
9 | it { is_expected.to validate_inclusion_of(:status).in_array Task::STATUSES }
10 |
11 | it { is_expected.to validate_presence_of :type }
12 | it { is_expected.to validate_inclusion_of(:type).in_array Task::TYPES }
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20210511164733_create_projects.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateProjects < ActiveRecord::Migration[6.1]
4 | def change
5 | create_table :projects, id: :uuid do |t|
6 | t.string :name, null: false
7 | t.string :description
8 | t.string :icon, null: false, default: "clipboard-list"
9 |
10 | t.references :user, null: true, type: :uuid, foreign_key: { on_delete: :nullify }
11 |
12 | t.timestamps
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/bin/webpack:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
4 | ENV["NODE_ENV"] ||= "development"
5 |
6 | require "pathname"
7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
8 | Pathname.new(__FILE__).realpath)
9 |
10 | require "bundler/setup"
11 |
12 | require "webpacker"
13 | require "webpacker/webpack_runner"
14 |
15 | APP_ROOT = File.expand_path("..", __dir__)
16 | Dir.chdir(APP_ROOT) do
17 | Webpacker::WebpackRunner.run(ARGV)
18 | end
19 |
--------------------------------------------------------------------------------
/config/initializers/permissions_policy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # Define an application-wide HTTP permissions policy. For further
3 | # information see https://developers.google.com/web/updates/2018/06/feature-policy
4 | #
5 | # Rails.application.config.permissions_policy do |f|
6 | # f.camera :none
7 | # f.gyroscope :none
8 | # f.microphone :none
9 | # f.usb :none
10 | # f.fullscreen :self
11 | # f.payment :self, "https://secure.example.com"
12 | # end
13 |
--------------------------------------------------------------------------------
/app/javascript/stylesheets/notifications.scss:
--------------------------------------------------------------------------------
1 | .notification {
2 | animation: notification 0.25s 1;
3 | -webkit-animation: notification 0.25s 1;
4 | animation-fill-mode: forwards;
5 |
6 | animation-delay: 2s;
7 | -webkit-animation-delay: 1s;
8 | -webkit-animation-fill-mode: forwards;
9 | }
10 |
11 | @keyframes notification {
12 | from { opacity: 1; }
13 | to { opacity: 0; }
14 | }
15 |
16 | @-webkit-keyframes notification {
17 | from { opacity: 1; }
18 | to { opacity: 0; }
19 | }
20 |
--------------------------------------------------------------------------------
/bin/webpack-dev-server:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
4 | ENV["NODE_ENV"] ||= "development"
5 |
6 | require "pathname"
7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
8 | Pathname.new(__FILE__).realpath)
9 |
10 | require "bundler/setup"
11 |
12 | require "webpacker"
13 | require "webpacker/dev_server_runner"
14 |
15 | APP_ROOT = File.expand_path("..", __dir__)
16 | Dir.chdir(APP_ROOT) do
17 | Webpacker::DevServerRunner.run(ARGV)
18 | end
19 |
--------------------------------------------------------------------------------
/app/controllers/calendar_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CalendarController < ProjectsController
4 | def show
5 | date = Date.new(params[:year].to_i, params[:month].to_i)
6 |
7 | past = date - 1.month
8 | future = date + 1.month
9 |
10 | render locals: { project: project, task: task, date: date, past: past, future: future }
11 | end
12 |
13 | private
14 |
15 | def task
16 | @task ||= Task.find(project.graph, params[:task_id]) if params[:task_id]
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/javascript/packs/application.scss:
--------------------------------------------------------------------------------
1 | @import '../stylesheets/base.scss';
2 | @import '../stylesheets/components.scss';
3 | @import '../stylesheets/notifications.scss';
4 | @import '../stylesheets/utilities.scss';
5 | @import '../stylesheets/rich_text.scss';
6 | @import '../stylesheets/graph.scss';
7 |
8 | @import url('https://fonts.googleapis.com/css2?family=Cantarell:wght@400;700&display=swap');
9 | @import 'collecticons-lib/dist/styles/icons';
10 |
11 | #modal-background {
12 | background-color: rgba(0, 0, 0, 0.6) !important;
13 | }
14 |
--------------------------------------------------------------------------------
/bin/spring:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"])
3 | gem "bundler"
4 | require "bundler"
5 |
6 | # Load Spring without loading other gems in the Gemfile, for speed.
7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring|
8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
9 | gem "spring", spring.version
10 | require "spring/binstub"
11 | rescue Gem::LoadError
12 | # Ignore when Spring is not installed.
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@hotwired/turbo-rails": "^7.0.0-beta.5",
4 | "@rails/ujs": "^6.1.3-1",
5 | "@rails/webpacker": "5.2.1",
6 | "collecticons-lib": "^4.1.0",
7 | "quill": "^1.3.7",
8 | "sass": "^1.32.12",
9 | "sass-loader": "10",
10 | "stimulus": "^2.0.0",
11 | "tailwindcss": "npm:@tailwindcss/postcss7-compat",
12 | "tailwindcss-stimulus-components": "^2.2.0",
13 | "webcola": "^3.4.0"
14 | },
15 | "devDependencies": {
16 | "imports-loader": "1.1.0",
17 | "webpack-dev-server": "^3.11.2"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/terraform/variables.tf:
--------------------------------------------------------------------------------
1 | ##
2 | # Providers
3 | #
4 | variable "hcloud_token" {
5 | type = string
6 | }
7 |
8 | variable "gandi_key" {
9 | type = string
10 | }
11 |
12 | ##
13 | # Services
14 | #
15 | variable "passwd" {
16 | description = "UNIX password hash for cloud user"
17 | type = string
18 | }
19 |
20 | ##
21 | # DNS
22 | #
23 | variable "domain_contact" {
24 | description = "Domain contact"
25 | type = map(string)
26 | }
27 |
28 | variable "domain_contact_extra" {
29 | description = "Domain contact extra parameters"
30 | type = map(string)
31 |
32 | default = {}
33 | }
34 |
--------------------------------------------------------------------------------
/bin/yarn:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_ROOT = File.expand_path('..', __dir__)
3 | Dir.chdir(APP_ROOT) do
4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR).
5 | select { |dir| File.expand_path(dir) != __dir__ }.
6 | product(["yarn", "yarn.cmd", "yarn.ps1"]).
7 | map { |dir, file| File.expand_path(file, dir) }.
8 | find { |file| File.executable?(file) }
9 |
10 | if yarn
11 | exec yarn, *ARGV
12 | else
13 | $stderr.puts "Yarn executable was not detected in the system."
14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install"
15 | exit 1
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/graph/graph.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Graph < RedisGraph
4 | attr_reader :name
5 |
6 | def initialize(name:)
7 | @name = name
8 |
9 | super(name, { url: ENV.fetch("REDIS_URL", "redis://redis:6379/1") })
10 | end
11 |
12 | def tasks
13 | match(:n, "Task")
14 | .return(:n)
15 | .map { |result| Task.load(result[:n].merge(graph: self)) }
16 | end
17 |
18 | def dsl
19 | DSL.new(self)
20 | end
21 |
22 | delegate :match, :merge, to: :dsl
23 |
24 | def inspect
25 | "#"
26 | end
27 |
28 | alias to_s inspect
29 | end
30 |
--------------------------------------------------------------------------------
/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Version of your assets, change this if you want to expire all your assets.
6 | Rails.application.config.assets.version = "1.0"
7 |
8 | # Add additional assets to the asset load path.
9 | # Rails.application.config.assets.paths << Emoji.images_path
10 |
11 | # Precompile additional assets.
12 | # application.js, application.css, and all non-JS/CSS in the app/assets
13 | # folder are already added.
14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css )
15 |
--------------------------------------------------------------------------------
/app/graph/node.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Node
4 | include ActiveModel::Model
5 | include ActiveModel::Attributes
6 | include ActiveModel::Validations
7 | include ActiveModel::Callbacks
8 |
9 | include Associations
10 | include Persistence::Node
11 | include Relationships
12 | include RichText
13 | include Timestamps
14 |
15 | attribute :graph
16 | attribute :id, :string
17 |
18 | validates :graph,
19 | presence: true
20 |
21 | def inspect
22 | "#<#{self.class.name} #{attributes.compact.map { |k, v| [k, v].join('=') }.join(' ')}>"
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # This file contains settings for ActionController::ParamsWrapper which
6 | # is enabled by default.
7 |
8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
9 | ActiveSupport.on_load(:action_controller) do
10 | wrap_parameters format: [:json]
11 | end
12 |
13 | # To enable root element in JSON for ActiveRecord objects.
14 | # ActiveSupport.on_load(:active_record) do
15 | # self.include_root_in_json = true
16 | # end
17 |
--------------------------------------------------------------------------------
/app/views/components/_notifications.html.erb:
--------------------------------------------------------------------------------
1 | <% if notice %>
2 |
3 | <%= heroicon "check-circle", options: { class: "w-4 h-4 mr-2 inline-block text-green-600" } %>
4 | <%= notice %>
5 |
6 | <% end %>
7 |
8 | <% if alert %>
9 |
10 | <%= heroicon "exclamation-circle", options: { class: "w-4 h-4 mr-2 inline-block text-red-500" } %>
11 | <%= alert %>
12 |
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
6 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) }
7 |
8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code
9 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'".
10 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"]
11 |
--------------------------------------------------------------------------------
/app/graph/edge.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Edge
4 | include ActiveModel::Model
5 | include ActiveModel::Attributes
6 | include ActiveModel::Validations
7 | include ActiveModel::Callbacks
8 |
9 | include Associations
10 | include Persistence::Edge
11 |
12 | TYPES = %w(related_to blocked_by child_of).freeze
13 |
14 | attribute :graph
15 | attribute :type, :string, default: "related_to"
16 |
17 | # TODO: use associations/attributes
18 | attr_accessor :from, :to
19 |
20 | validates :graph,
21 | presence: true
22 |
23 | validates :type,
24 | presence: true,
25 | inclusion: { in: TYPES }
26 | end
27 |
--------------------------------------------------------------------------------
/.development.env:
--------------------------------------------------------------------------------
1 | # Docker compose name prefix
2 | COMPOSE_PROJECT_NAME=codered
3 |
4 | # ---------------------------------------------------------------------------------------------------------------------
5 | # General
6 | # ---------------------------------------------------------------------------------------------------------------------
7 |
8 | # Rails
9 | RAILS_ENV=development
10 | RAILS_LOG_TO_STDOUT=1
11 |
12 | # Generate a secret using `rails secret`
13 | SECRET_KEY_BASE=mysecretkeybase
14 |
15 | # Database credentials
16 | #PG_HOST=postgres
17 | #PG_USER=postgres
18 | #PG_PASSWORD=postgres
19 | #PG_DATABASE=headbanger_development
20 |
21 | # Reset database interval (in minutes)
22 | # RESET_INTERVAL=15
23 |
--------------------------------------------------------------------------------
/app/models/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class User < ApplicationRecord
4 | has_many :projects,
5 | dependent: :nullify
6 |
7 | validates :email,
8 | presence: true
9 |
10 | validates :name,
11 | presence: true
12 |
13 | def avatar
14 | FFaker::Avatar.image(nil, "128x128")
15 | end
16 | end
17 |
18 | # == Schema Information
19 | #
20 | # Table name: users
21 | #
22 | # id :uuid not null, primary key
23 | # email :string not null
24 | # name :string not null
25 | # created_at :datetime not null
26 | # updated_at :datetime not null
27 | #
28 | # Indexes
29 | #
30 | # index_users_on_email (email)
31 | #
32 |
--------------------------------------------------------------------------------
/app/views/tasks/_modal_form.html.erb:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/lib/tasks/database/seed.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | namespace :database do
4 | desc "Seed production and development database"
5 | task seed: %w(seed:production seed:development)
6 |
7 | namespace :seed do
8 | desc "Seed production database"
9 | task production: :environment do
10 | # Turn off SQL log
11 | ActiveRecord::Base.logger = nil
12 |
13 | Dir[Rails.root.join("db/seeds/*.rb")].each { |f| require f }
14 | end
15 |
16 | desc "Seed development database"
17 | task development: :environment do
18 | # Turn off SQL log
19 | ActiveRecord::Base.logger = nil
20 |
21 | Dir[Rails.root.join("db/seeds/development/*.rb")].each { |f| require f }
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/spec/graph/concerns/timestamps_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Timestamps do
4 | subject(:node) { build(:node) }
5 |
6 | describe "#created_at" do
7 | it "is empty when object has not been persisted" do
8 | expect(node.created_at).to be_nil
9 | end
10 |
11 | it "is set when object was persisted" do
12 | node.save
13 |
14 | expect(node.created_at).not_to be_nil
15 | end
16 | end
17 |
18 | describe "#updated_at" do
19 | it "is empty when object has not been persisted" do
20 | expect(node.updated_at).to be_nil
21 | end
22 |
23 | it "is set when object was persisted" do
24 | node.save
25 |
26 | expect(node.updated_at).not_to be_nil
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new inflection rules using the following format. Inflections
6 | # are locale specific, and you may define rules for as many different
7 | # locales as you wish. All of these examples are active by default:
8 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
9 | # inflect.plural /^(ox)$/i, '\1en'
10 | # inflect.singular /^(ox)en/i, '\1'
11 | # inflect.irregular 'person', 'people'
12 | # inflect.uncountable %w( fish sheep )
13 | # end
14 |
15 | # These inflection rules are supported but not enabled by default:
16 | ActiveSupport::Inflector.inflections(:en) do |inflect|
17 | inflect.acronym "UUID"
18 | inflect.acronym "DSL"
19 | end
20 |
--------------------------------------------------------------------------------
/spec/graph/concerns/associations_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Associations do
4 | subject(:task) { build(:task) }
5 |
6 | describe "#user" do
7 | it "returns nil when user_id was not set" do
8 | task.user_id = nil
9 |
10 | expect(task.user).to be_nil
11 | end
12 |
13 | it "returns a user" do
14 | user = create(:user)
15 |
16 | task.user_id = user.id
17 |
18 | expect(task.user).to eq user
19 | end
20 | end
21 |
22 | describe "#user=" do
23 | it "clears the user_id attribute" do
24 | task.user = nil
25 |
26 | expect(task.user_id).to be_nil
27 | end
28 |
29 | it "sets the user_id attribute" do
30 | user = create(:user)
31 |
32 | task.user = user
33 |
34 | expect(task.user_id).to eq user.id
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/app/graph/concerns/associations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Associations
4 | extend ActiveSupport::Concern
5 |
6 | included do
7 | class_attribute :associations
8 |
9 | self.associations = {}
10 | end
11 |
12 | class_methods do
13 | def association(name, class_name = name, optional: false)
14 | associations[name] = { class_name: class_name, optional: optional }
15 |
16 | attribute :"#{name}_id", :string
17 |
18 | unless optional
19 | validates :"#{name}_id",
20 | presence: true
21 | end
22 |
23 | define_method(name) do
24 | id = send(:"#{name}_id")
25 |
26 | class_name.to_s.camelize.constantize.find(id) if id.present?
27 | end
28 |
29 | define_method(:"#{name}=") do |model|
30 | send(:"#{name}_id=", model&.id)
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/models/project.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Project < ApplicationRecord
4 | belongs_to :user,
5 | optional: true
6 |
7 | validates :name,
8 | presence: true
9 |
10 | def graph
11 | @graph ||= Graph.new(name: id)
12 | end
13 |
14 | delegate :tasks, to: :graph
15 | end
16 |
17 | # == Schema Information
18 | #
19 | # Table name: projects
20 | #
21 | # id :uuid not null, primary key
22 | # description :string
23 | # icon :string default("clipboard-list"), not null
24 | # name :string not null
25 | # created_at :datetime not null
26 | # updated_at :datetime not null
27 | # user_id :uuid
28 | #
29 | # Indexes
30 | #
31 | # index_projects_on_user_id (user_id)
32 | #
33 | # Foreign Keys
34 | #
35 | # fk_rails_... (user_id => users.id) ON DELETE => nullify
36 | #
37 |
--------------------------------------------------------------------------------
/app/javascript/stylesheets/rich_text.scss:
--------------------------------------------------------------------------------
1 | .ql-clipboard {
2 | @apply hidden;
3 | }
4 |
5 | .ql-editor {
6 | @apply focus:outline-none;
7 |
8 | font-family: Cantarell, sans-serif;
9 | }
10 |
11 | .ql-blank::before {
12 | @apply absolute text-gray-400;
13 |
14 | content: 'Description of your task...'
15 | }
16 |
17 | .ql-container {
18 | @apply whitespace-pre-wrap p-0;
19 |
20 | ol {
21 | @apply list-inside list-decimal py-3;
22 | }
23 |
24 | ul {
25 | @apply list-inside list-disc py-3;
26 | }
27 |
28 | blockquote {
29 | @apply bg-gray-50 border-l border-indigo-200 p-2;
30 | }
31 |
32 | pre {
33 | @apply bg-gray-50 p-2 my-2 text-sm whitespace-pre-wrap;
34 | }
35 | }
36 |
37 | @layer components {
38 | .rich-text-button {
39 | @apply p-2 rounded hover:bg-gray-100 focus:outline-none;
40 |
41 | &.ql-active {
42 | @apply bg-gray-100;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/helpers/task_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module TaskHelper
4 | TYPES = {
5 | idea: { icon: "light-bulb", color: "purple-400" },
6 | goal: { icon: "academic-cap", color: "green-500" },
7 | epic: { icon: "lightning-bolt", color: "yellow-500" },
8 | feature: { icon: "beaker", color: "blue-600" },
9 | task: { icon: "briefcase", color: "gray-600" },
10 | bug: { icon: "fire", color: "red-600" },
11 | }.freeze
12 |
13 | STATUSES = {
14 | todo: { color: "gray-800" },
15 | in_progress: { color: "indigo-500" },
16 | review: { color: "pink-500" },
17 | done: { color: "green-600" },
18 | }.freeze
19 |
20 | def icon_for_type(type)
21 | TYPES.dig(type.to_sym, :icon)
22 | end
23 |
24 | def color_for_type(type)
25 | TYPES.dig(type.to_sym, :color)
26 | end
27 |
28 | def color_for_status(status)
29 | STATUSES.dig(status.to_sym, :color)
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/bin/update:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "fileutils"
5 |
6 | # path to your application root.
7 | APP_ROOT = File.expand_path("..", __dir__)
8 |
9 | def system!(*args)
10 | system(*args) || abort("\n== Command #{args} failed ==")
11 | end
12 |
13 | FileUtils.chdir APP_ROOT do
14 | # This script is a way to update your development environment automatically.
15 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome.
16 | # Add necessary setup steps to this file.
17 |
18 | puts "== Installing local app dependencies =="
19 | system! "gem install bundler --conservative"
20 | system("bundle check") || system!("bundle install")
21 | system! "yarn"
22 |
23 | puts "\n== Installing remote app dependencies =="
24 | system! "docker-compose run --rm -u root app bundle install"
25 | system! "docker-compose run --rm -u root app yarn"
26 | end
27 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | x-app: &app
4 | build: .
5 | volumes:
6 | - .:/app/:cached
7 | - bundle:/usr/local/bundle/
8 | - npm:/app/node_modules/
9 | depends_on:
10 | - postgres
11 | - redis
12 | env_file: .development.env
13 |
14 | services:
15 | postgres:
16 | image: postgres:12
17 | volumes:
18 | - postgres:/var/lib/postgresql/data/
19 | ports:
20 | - "5432:5432"
21 | environment:
22 | POSTGRES_USER: postgres
23 | POSTGRES_PASSWORD: postgres
24 |
25 | redis:
26 | image: redislabs/redismod
27 | command: --appendonly yes --loadmodule /usr/lib/redis/modules/redisgraph.so
28 | volumes:
29 | - redis:/data/
30 |
31 | app:
32 | <<: *app
33 | ports:
34 | - "3000:3000"
35 |
36 | reset:
37 | <<: *app
38 | command: bin/reset
39 | restart: "no"
40 |
41 | volumes:
42 | postgres:
43 | redis:
44 | bundle:
45 | npm:
46 |
--------------------------------------------------------------------------------
/app/graph/task.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Task < Node
4 | STATUSES = %w(todo in_progress review done).freeze
5 | TYPES = %w(idea goal epic feature task bug).freeze
6 |
7 | attribute :title, :string
8 | attribute :description, :string
9 | attribute :deadline, :date
10 | attribute :status, :string, default: "todo"
11 | attribute :type, :string, default: "task"
12 |
13 | association :user, optional: true
14 |
15 | relationship :blocked_by
16 | relationship :blocks,
17 | inverse_of: :blocked_by
18 |
19 | relationship :child_of
20 | relationship :parent_of,
21 | inverse_of: :child_of
22 |
23 | relationship :related_to
24 |
25 | validates :title,
26 | presence: true
27 |
28 | validates :status,
29 | presence: true,
30 | inclusion: { in: STATUSES }
31 |
32 | validates :type,
33 | presence: true,
34 | inclusion: { in: TYPES }
35 | end
36 |
--------------------------------------------------------------------------------
/app/views/tasks/_task.html.erb:
--------------------------------------------------------------------------------
1 | <%= link_to edit_project_task_path(project_id: project.id, id: task.id), class: "m-8", data: { action: "click->task-modal#open", "turbo-frame": "task" } do %>
2 |
3 |
<%= task.title %>
4 |
5 |
6 |
7 | <%= heroicon icon_for_type(task.type), options: { class: "h-4 w-4 inline-block" } %>
8 | <%= task.type.capitalize %>
9 |
10 |
11 | <%= heroicon "user", options: { class: "h-4 w-4 inline-block text-gray-600", title: task.user.name } if task.user %>
12 |
13 |
14 | <% end %>
15 |
--------------------------------------------------------------------------------
/app/javascript/controllers/index.js:
--------------------------------------------------------------------------------
1 | // Load all the controllers within this directory and all subdirectories.
2 | // Controller files must be named *_controller.js.
3 |
4 | import { Application } from 'stimulus'
5 | import { definitionsFromContext } from 'stimulus/webpack-helpers'
6 |
7 | const application = Application.start()
8 | const context = require.context('controllers', true, /_controller\.js$/)
9 | application.load(definitionsFromContext(context))
10 |
11 | // Import and register all TailwindCSS Components
12 | import { Alert, Autosave, Dropdown, Modal, Tabs, Popover, Toggle, Slideover } from 'tailwindcss-stimulus-components'
13 |
14 | application.register('alert', Alert)
15 | application.register('autosave', Autosave)
16 | application.register('dropdown', Dropdown)
17 | application.register('modal', Modal)
18 | application.register('tabs', Tabs)
19 | application.register('popover', Popover)
20 | application.register('toggle', Toggle)
21 | application.register('slideover', Slideover)
22 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # The following keys must be escaped otherwise they will not be retrieved by
20 | # the default I18n backend:
21 | #
22 | # true, false, on, off, yes, no
23 | #
24 | # Instead, surround them with single quotes.
25 | #
26 | # en:
27 | # 'true': 'foo'
28 | #
29 | # To learn more, please read the Rails Internationalization guide
30 | # available at https://guides.rubyonrails.org/i18n.html.
31 |
32 | en:
33 | hello: "Hello world"
34 |
--------------------------------------------------------------------------------
/spec/graph/renderer_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Renderer do
4 | subject(:renderer) { described_class.new(project) }
5 |
6 | let(:project) { create(:project) }
7 |
8 | let!(:task0) { create(:task, graph: project.graph, type: "epic") }
9 | let!(:task1) { create(:task, graph: project.graph, type: "task") }
10 |
11 | before { create(:edge, graph: project.graph, from: task0, type: "related_to", to: task1) }
12 |
13 | describe "nodes" do
14 | it "returns a list of nodes" do
15 | expect(renderer.to_h.fetch(:nodes))
16 | .to match_array [
17 | including(id: task0.id, label: task0.title),
18 | including(id: task1.id, label: task1.title),
19 | ]
20 | end
21 | end
22 |
23 | describe "edges" do
24 | it "returns a list of edges" do
25 | expect(renderer.to_h.fetch(:edges))
26 | .to match_array [
27 | including(source: 0, target: 1, label: "Related To"),
28 | ]
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/terraform/.gitignore:
--------------------------------------------------------------------------------
1 | # Local .terraform directories
2 | **/.terraform/*
3 |
4 | # .tfstate files
5 | *.tfstate
6 | *.tfstate.*
7 |
8 | # Crash log files
9 | crash.log
10 |
11 | # Exclude all .tfvars files, which are likely to contain sentitive data, such as
12 | # password, private keys, and other secrets. These should not be part of version
13 | # control as they are data points which are potentially sensitive and subject
14 | # to change depending on the environment.
15 | #
16 | *.tfvars
17 |
18 | # Ignore override files as they are usually used to override resources locally and so
19 | # are not checked in
20 | override.tf
21 | override.tf.json
22 | *_override.tf
23 | *_override.tf.json
24 |
25 | # Include override files you do wish to add to version control using negated pattern
26 | #
27 | # !example_override.tf
28 |
29 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
30 | # example: *tfplan*
31 |
32 | # Ignore CLI configuration files
33 | .terraformrc
34 | terraform.rc
35 |
--------------------------------------------------------------------------------
/app/javascript/stylesheets/graph.scss:
--------------------------------------------------------------------------------
1 | .background {
2 | @apply text-gray-100 fill-current;
3 | }
4 |
5 | .node {
6 | @apply cursor-pointer stroke-current text-gray-200 drop-shadow-md;
7 |
8 | fill: white;
9 |
10 | stroke-width: 1px;
11 |
12 | filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, .2));
13 | }
14 |
15 | .link {
16 | stroke: #999;
17 | stroke-width: 2px;
18 | stroke-opacity: 1;
19 |
20 | marker-end: url(#end-arrow);
21 | }
22 |
23 | .link-label {
24 | @apply text-gray-600 fill-current;
25 |
26 | font-family: Cantarell;
27 | font-size: 10px;
28 | text-anchor: middle
29 | }
30 |
31 | .label {
32 | @apply cursor-pointer fill-current;
33 |
34 | font-family: Cantarell;
35 | font-size: 16px;
36 | text-anchor: start;
37 |
38 | text-overflow: ellipsis;
39 |
40 | white-space: nowrap;
41 | overflow: hidden;
42 | }
43 |
44 | .icon {
45 | @apply cursor-pointer fill-current;
46 | }
47 |
48 | .type {
49 | @apply cursor-pointer fill-current;
50 |
51 | font-size: 14px;
52 | font-family: Cantarell;
53 | }
54 |
--------------------------------------------------------------------------------
/spec/factories/graph.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | FactoryBot.define do
4 | factory :graph, class: "Graph" do
5 | skip_create
6 |
7 | initialize_with { Graph.new(name: name) }
8 |
9 | name { FFaker::Lorem.word }
10 | end
11 |
12 | factory :dsl, class: "DSL" do
13 | skip_create
14 |
15 | initialize_with { DSL.new(graph) }
16 |
17 | graph
18 | end
19 |
20 | factory :node, class: "Node" do
21 | to_create(&:save)
22 |
23 | graph
24 | end
25 |
26 | factory :edge, class: "Edge" do
27 | to_create(&:save)
28 |
29 | type { Edge::TYPES.sample }
30 |
31 | from { create(:node, graph: graph) }
32 | to { create(:node, graph: graph) }
33 |
34 | graph
35 | end
36 |
37 | factory :task, parent: :node, class: "Task" do
38 | title { FFaker::Lorem.sentence(5) }
39 | description { FFaker::Lorem.sentence(20) }
40 | deadline { FFaker::Time.datetime }
41 | status { Task::STATUSES.sample }
42 | type { Task::TYPES.sample }
43 |
44 | association :user
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/Dockerfile.orig:
--------------------------------------------------------------------------------
1 | FROM ruby:3.0.1-alpine
2 |
3 | MAINTAINER Florian Dejonckheere
4 | LABEL org.opencontainers.image.source https://github.com/floriandejonckheere/code-red
5 |
6 | ENV RUNTIME_DEPS postgresql
7 | ENV BUILD_DEPS build-base curl-dev git postgresql-dev cmake nodejs yarn
8 |
9 | ENV LC_ALL en_US.UTF-8
10 | ENV LANG en_US.UTF-8
11 |
12 | ENV APP_HOME /app/
13 | WORKDIR $APP_HOME/
14 |
15 | # Add user
16 | ARG USER=docker
17 | ARG UID=1000
18 | ARG GID=1000
19 |
20 | RUN addgroup -g $GID $USER
21 | RUN adduser -D -u $UID -G $USER -h $APP_HOME/ $USER
22 |
23 | # Install system dependencies
24 | RUN apk add --no-cache $BUILD_DEPS $RUNTIME_DEPS
25 |
26 | # Install Bundler
27 | RUN gem update --system && gem install bundler
28 |
29 | # Install Gem dependencies
30 | ADD Gemfile $APP_HOME/
31 | ADD Gemfile.lock $APP_HOME/
32 |
33 | RUN bundle install
34 |
35 | # Add application
36 | ADD . $APP_HOME/
37 |
38 | RUN mkdir -p $APP_HOME/tmp/pids/
39 |
40 | RUN chown -R $UID:$GID $APP_HOME/
41 |
42 | # Change user
43 | USER $USER
44 |
45 | CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Redis Developer
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
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "fileutils"
3 |
4 | # path to your application root.
5 | APP_ROOT = File.expand_path('..', __dir__)
6 |
7 | def system!(*args)
8 | system(*args) || abort("\n== Command #{args} failed ==")
9 | end
10 |
11 | FileUtils.chdir APP_ROOT do
12 | # This script is a way to set up or update your development environment automatically.
13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome.
14 | # Add necessary setup steps to this file.
15 |
16 | puts '== Installing dependencies =='
17 | system! 'gem install bundler --conservative'
18 | system('bundle check') || system!('bundle install')
19 |
20 | # puts "\n== Copying sample files =="
21 | # unless File.exist?('config/database.yml')
22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
23 | # end
24 |
25 | puts "\n== Preparing database =="
26 | system! 'bin/rails db:prepare'
27 |
28 | puts "\n== Removing old logs and tempfiles =="
29 | system! 'bin/rails log:clear tmp:clear'
30 |
31 | puts "\n== Restarting application server =="
32 | system! 'bin/rails restart'
33 | end
34 |
--------------------------------------------------------------------------------
/app/graph/concerns/clause.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ##
4 | # Simple clause: join elements with spaces
5 | #
6 | class Clause < Array
7 | def to_query
8 | join(" ")
9 | end
10 |
11 | def +(other)
12 | self.class.new(super)
13 | end
14 |
15 | ##
16 | # Return clause: join nodes with comma
17 | #
18 | class Return < Clause
19 | def to_query
20 | join(", ")
21 | end
22 | end
23 |
24 | ##
25 | # Match clause: join nodes with comma, and relationships between nodes with spaces:
26 | # [(n), (m)] => MATCH (n), (m)
27 | # [(n), -[r]->, (m)] => MATCH (n) -[r]-> (m)
28 | #
29 | class Match < Clause
30 | def to_query
31 | # Determine separators: if previous element ends with '>' or next element
32 | # starts with '-', use space as separator, otherwise use comma space
33 | separators = each_cons(2).map do |one, two|
34 | next " " if one.ends_with?(">") || two.starts_with?("-")
35 |
36 | ", "
37 | end
38 |
39 | # Zip query elements with separators and compact (because there are n - 1 separators)
40 | zip(separators).flatten.compact.join
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/app/graph/concerns/relationships.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Relationships
4 | extend ActiveSupport::Concern
5 |
6 | included do
7 | class_attribute :relationships
8 |
9 | self.relationships = {}
10 | end
11 |
12 | class_methods do
13 | def relationship(name, type = name.to_s, inverse_of: nil)
14 | relationships[name] = { type: type, inverse_of: inverse_of }
15 |
16 | define_method(name) do
17 | return [] unless persisted?
18 |
19 | if inverse_of
20 | Edge
21 | .where(graph, from: self.class.new, type: inverse_of, to: self)
22 | .map(&:from)
23 | else
24 | Edge
25 | .where(graph, from: self, type: type, to: self.class.new)
26 | .map(&:to)
27 | end
28 | end
29 | end
30 |
31 | # Invert relationship if necessary (e.g. blocks => blocked_by)
32 | def invert(from: nil, type: nil, to: nil)
33 | inverse_of = relationships.dig(type.to_sym, :inverse_of)
34 |
35 | if inverse_of
36 | from, to = to, from
37 |
38 | type = inverse_of
39 | end
40 |
41 | { from: from, type: type, to: to }
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/app/javascript/controllers/rich_text_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from 'stimulus'
2 | import Quill from 'quill'
3 |
4 | // Based on https://mattsears.com/articles/2021/01/26/creating-a-simple-rich-text-editor-with-quill-stimulus-and-tailwind-css/
5 | export default class extends Controller {
6 | static targets = ['container', 'hidden', 'toolbar']
7 |
8 | quill = null
9 |
10 | connect() {
11 | this.quillInit()
12 | }
13 |
14 | quillInit() {
15 | this.quill = new Quill(this.containerTarget, this.quillOption)
16 | let self = this
17 |
18 | this.quill.on('text-change', function(delta) {
19 | self.hiddenTarget.value = self.quill.root.innerHTML
20 |
21 | // Dispatch change event, so autosave triggers
22 | self.hiddenTarget.dispatchEvent(new Event('change'))
23 | })
24 | }
25 |
26 | undo() {
27 | this.quill.history.undo()
28 | }
29 |
30 | redo() {
31 | this.quill.history.redo()
32 | }
33 |
34 | get quillOption() {
35 | return {
36 | modules: {
37 | toolbar: this.toolbarTarget,
38 | history: {
39 | delay: 1000,
40 | maxStack: 100,
41 | userOnly: false
42 | },
43 | },
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/spec/graph/concerns/relationships_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Relationships do
4 | subject(:task) { create(:task) }
5 |
6 | it "returns empty when it does not block tasks" do
7 | expect(task.blocks).to be_empty
8 | end
9 |
10 | it "returns a collection when it blocks tasks" do
11 | blocked_by = create(:task, graph: task.graph)
12 |
13 | create(:edge, graph: task.graph, from: task, type: "blocked_by", to: blocked_by)
14 |
15 | expect(task.blocked_by).to eq [blocked_by]
16 | expect(blocked_by.blocks).to eq [task]
17 | end
18 |
19 | describe ".invert" do
20 | it "inverts if the relationship has an inverse" do
21 | Task.invert(from: "from", type: :blocks, to: "to") in { from: from, type: type, to: to }
22 |
23 | expect(from).to eq "to"
24 | expect(type).to eq :blocked_by
25 | expect(to).to eq "from"
26 | end
27 |
28 | it "does not invert if the relationship does not have an inverse" do
29 | Task.invert(from: "from", type: :blocked_by, to: "to") in { from: from, type: type, to: to }
30 |
31 | expect(from).to eq "from"
32 | expect(type).to eq :blocked_by
33 | expect(to).to eq "to"
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ruby:3.0.2-alpine
2 |
3 | MAINTAINER Florian Dejonckheere
4 | LABEL org.opencontainers.image.source https://github.com/floriandejonckheere/code-red
5 |
6 | ENV RUNTIME_DEPS postgresql
7 | ENV BUILD_DEPS build-base curl-dev git postgresql-dev cmake nodejs yarn
8 |
9 | ENV LC_ALL en_US.UTF-8
10 | ENV LANG en_US.UTF-8
11 |
12 | ENV APP_HOME /app/
13 | WORKDIR $APP_HOME/
14 |
15 | # Add user
16 | ARG USER=docker
17 | ARG UID=1000
18 | ARG GID=1000
19 |
20 | RUN addgroup -g $GID $USER
21 | RUN adduser -D -u $UID -G $USER -h $APP_HOME/ $USER
22 |
23 | # Install system dependencies
24 | RUN apk add --no-cache $BUILD_DEPS $RUNTIME_DEPS
25 |
26 | # Install Bundler
27 | RUN gem update --system && gem install bundler
28 |
29 | # Install Gem dependencies
30 | ADD Gemfile $APP_HOME/
31 | ADD Gemfile.lock $APP_HOME/
32 |
33 | RUN bundle install
34 |
35 | # Install NPM dependencies
36 | ADD package.json /app/
37 | ADD yarn.lock /app/
38 |
39 | RUN yarn install
40 |
41 | # Add application
42 | ADD . $APP_HOME/
43 |
44 | RUN mkdir -p $APP_HOME/tmp/pids/
45 |
46 | RUN chown -R $UID:$GID $APP_HOME/
47 |
48 | # Change user
49 | USER $USER
50 |
51 | CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
52 |
--------------------------------------------------------------------------------
/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket
23 |
24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/app/graph/concerns/rich_text.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ##
4 | # Mimick ActionText's API without the dependency on ActiveRecord::Associations
5 | #
6 | module RichText
7 | extend ActiveSupport::Concern
8 |
9 | included do
10 | class_attribute :rich_texts
11 |
12 | self.rich_texts = []
13 |
14 | after_save :save_rich_text
15 | after_destroy :destroy_rich_text
16 |
17 | def save_rich_text
18 | rich_texts.each(&:save!)
19 | end
20 |
21 | def destroy_rich_text
22 | rich_texts.each(&:destroy!)
23 | end
24 | end
25 |
26 | class_methods do
27 | def rich_text(name)
28 | rich_texts << name
29 |
30 | define_method(name) do
31 | send(:"rich_text_#{name}") || send(:"build_rich_text_#{name}")
32 | end
33 |
34 | define_method(:"#{name}?") do
35 | send(:"rich_text_#{name}").present?
36 | end
37 |
38 | define_method(:"#{name}=") do |body|
39 | send(name).body = body
40 | end
41 |
42 | define_method(:"rich_text_#{name}") do
43 | ActionText::RichText.find_by(record_id: id, name: name)
44 | end
45 |
46 | define_method(:"build_rich_text_#{name}") do
47 | ActionText::RichText.new(record_id: id, name: name)
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/app/javascript/controllers/task_dropdown_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from 'stimulus';
2 |
3 | export default class extends Controller {
4 | static targets = ['menu', 'title', 'value', 'button']
5 |
6 | static values = {
7 | // Dropdown state
8 | open: Boolean,
9 | }
10 |
11 | toggle() {
12 | this.openValue = !this.openValue
13 | }
14 |
15 | hide(event) {
16 | if (this.element.contains(event.target) === false && this.openValue) {
17 | this.openValue = false
18 | }
19 | }
20 |
21 | set(e) {
22 | // Set hidden form input
23 | this.valueTarget.value = e.currentTarget.dataset.value
24 |
25 | // Set dropdown
26 | this.titleTarget.innerHTML = e.currentTarget.dataset.title || e.currentTarget.innerHTML
27 |
28 | // Dispatch change event, so autosave triggers
29 | this.valueTarget.dispatchEvent(new Event('change'))
30 | }
31 |
32 | openValueChanged() {
33 | this.openValue ? this._show() : this._hide()
34 | }
35 |
36 | _show() {
37 | setTimeout(() => {
38 | this.menuTarget.classList.remove('hidden')
39 | this.buttonTarget.classList.add('bg-gray-100')
40 | })
41 | }
42 |
43 | _hide() {
44 | setTimeout(() => {
45 | this.menuTarget.classList.add('hidden')
46 | this.buttonTarget.classList.remove('bg-gray-100')
47 | })
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/marketplace.json:
--------------------------------------------------------------------------------
1 | {
2 | "app_name": "Code Red - A Task Management app",
3 | "description": "Task management app using Redis to represent tasks and resources",
4 | "rank": 47,
5 | "type": "Full App",
6 | "contributed_by": "Community",
7 | "repo_url": "https://github.com/redis-developer/code-red",
8 | "preview_image_url": "https://raw.githubusercontent.com/redis-developer/code-red/master/images/app_preview_image.png",
9 | "download_url": "https://github.com/redis-developer/code-red/archive/refs/heads/master.zip",
10 | "hosted_url": "",
11 | "quick_deploy": "false",
12 | "deploy_buttons": [
13 | {
14 | "heroku": "https://heroku.com/deploy?template=https://github.com/redis-developer/code-red.git"
15 | },
16 | {
17 | "Google": "https://deploy.cloud.run/?git_repo=https://github.com/redis-developer/code-red.git"
18 | }
19 | ],
20 | "language": [
21 | "Ruby"
22 | ],
23 | "redis_commands": [
24 | "GRAPH.QUERY"
25 | ],
26 | "redis_use_cases": [],
27 | "redis_features": [
28 | "Graph"
29 | ],
30 | "app_image_urls": [],
31 | "youtube_url": "https://www.youtube.com/watch?v=PjqnpUQCD9U",
32 | "special_tags": [
33 | "Hackathon"
34 | ],
35 | "verticals": [
36 | "Others"
37 | ],
38 | "markdown": "https://raw.githubusercontent.com/redis-developer/code-red/master/README.md"
39 | }
--------------------------------------------------------------------------------
/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # Be sure to restart your server when you modify this file.
3 |
4 | # Define an application-wide content security policy
5 | # For further information see the following documentation
6 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
7 |
8 | # Rails.application.config.content_security_policy do |policy|
9 | # policy.default_src :self, :https
10 | # policy.font_src :self, :https, :data
11 | # policy.img_src :self, :https, :data
12 | # policy.object_src :none
13 | # policy.script_src :self, :https
14 | # policy.style_src :self, :https
15 |
16 | # # Specify URI for violation reports
17 | # # policy.report_uri "/csp-violation-report-endpoint"
18 | # end
19 |
20 | # If you are using UJS then enable automatic nonce generation
21 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
22 |
23 | # Set the nonce only to specific directives
24 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src)
25 |
26 | # Report CSP violations to a specified URI
27 | # For further information see the following documentation:
28 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
29 | # Rails.application.config.content_security_policy_report_only = true
30 |
--------------------------------------------------------------------------------
/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "boot"
4 |
5 | require "rails"
6 | # Pick the frameworks you want:
7 | require "active_model/railtie"
8 | require "active_job/railtie"
9 | require "active_record/railtie"
10 | require "active_storage/engine"
11 | require "action_controller/railtie"
12 | require "action_mailer/railtie"
13 | require "action_mailbox/engine"
14 | require "action_text/engine"
15 | require "action_view/railtie"
16 | require "action_cable/engine"
17 | # require "sprockets/railtie"
18 | # require "rails/test_unit/railtie"
19 |
20 | # Require the gems listed in Gemfile, including any gems
21 | # you've limited to :test, :development, or :production.
22 | Bundler.require(*Rails.groups)
23 |
24 | module CodeRed
25 | class Application < Rails::Application
26 | # Initialize configuration defaults for originally generated Rails version.
27 | config.load_defaults 6.1
28 |
29 | # Configuration for the application, engines, and railties goes here.
30 | #
31 | # These settings can be overridden in specific environments using the files
32 | # in config/environments, which are processed later.
33 | #
34 | # config.time_zone = "Central Time (US & Canada)"
35 | # config.eager_load_paths << Rails.root.join("extras")
36 |
37 | config.autoload_paths << Rails.root.join("lib")
38 |
39 | # Don't generate system test files.
40 | config.generators.system_tests = nil
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/app/controllers/tasks_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class TasksController < ProjectsController
4 | def index
5 | render locals: { project: project, users: users }
6 | end
7 |
8 | def new
9 | @task = Task.new(graph: project.graph)
10 |
11 | render locals: { project: project, task: task }
12 | end
13 |
14 | def edit
15 | render locals: { project: project, task: task }
16 | end
17 |
18 | def create
19 | @task = Task.new(graph: project.graph)
20 |
21 | update
22 | end
23 |
24 | def update
25 | task.update(task_params)
26 |
27 | respond_to do |format|
28 | format.turbo_stream do
29 | render turbo_stream: turbo_stream.replace("task_form", partial: "tasks/form", locals: { project: project, task: task, notice: "Task saved" })
30 | end
31 | end
32 | end
33 |
34 | def destroy
35 | task.destroy
36 |
37 | respond_to do |format|
38 | format.html { redirect_to project_tasks_path(project_id: project.id), notice: "Task deleted" }
39 | end
40 | end
41 |
42 | private
43 |
44 | def task
45 | @task ||= Task.find(project.graph, params[:id])
46 | end
47 |
48 | def users
49 | @users ||= User.all.order(:name)
50 | end
51 |
52 | def task_params
53 | params
54 | .require(:task)
55 | .permit(
56 | :title,
57 | :description,
58 | :status,
59 | :type,
60 | :user_id,
61 | :deadline,
62 | )
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/db/migrate/20210504172530_create_active_storage_tables.active_storage.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This migration comes from active_storage (originally 20170806125915)
4 | # rubocop:disable Rails/CreateTableWithTimestamps
5 | class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
6 | def change
7 | create_table :active_storage_blobs do |t|
8 | t.string :key, null: false
9 | t.string :filename, null: false
10 | t.string :content_type
11 | t.text :metadata
12 | t.string :service_name, null: false
13 | t.bigint :byte_size, null: false
14 | t.string :checksum, null: false
15 | t.datetime :created_at, null: false
16 |
17 | t.index [:key], unique: true
18 | end
19 |
20 | create_table :active_storage_attachments do |t|
21 | t.string :name, null: false
22 | t.references :record, null: false, polymorphic: true, index: false, type: :uuid
23 | t.references :blob, null: false
24 |
25 | t.datetime :created_at, null: false
26 |
27 | t.index [:record_type, :record_id, :name, :blob_id], name: "index_active_storage_attachments_uniqueness", unique: true
28 | t.foreign_key :active_storage_blobs, column: :blob_id
29 | end
30 |
31 | create_table :active_storage_variant_records, id: :uuid do |t|
32 | t.belongs_to :blob, null: false, index: false
33 | t.string :variation_digest, null: false
34 |
35 | t.index [:blob_id, :variation_digest], name: "index_active_storage_variant_records_uniqueness", unique: true
36 | t.foreign_key :active_storage_blobs, column: :blob_id
37 | end
38 | end
39 | end
40 | # rubocop:enable Rails/CreateTableWithTimestamps
41 |
--------------------------------------------------------------------------------
/.overcommit.yml:
--------------------------------------------------------------------------------
1 | # Use this file to configure the Overcommit hooks you wish to use. This will
2 | # extend the default configuration defined in:
3 | # https://github.com/sds/overcommit/blob/master/config/default.yml
4 | #
5 | # At the topmost level of this YAML file is a key representing type of hook
6 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can
7 | # customize each hook, such as whether to only run it on certain files (via
8 | # `include`), whether to only display output if it fails (via `quiet`), etc.
9 | #
10 | # For a complete list of hooks, see:
11 | # https://github.com/sds/overcommit/tree/master/lib/overcommit/hook
12 | #
13 | # For a complete list of options that you can use to customize hooks, see:
14 | # https://github.com/sds/overcommit#configuration
15 | #
16 | # Uncomment the following lines to make the configuration take effect.
17 |
18 | PreCommit:
19 | Fasterer:
20 | enabled: true
21 |
22 | FixMe:
23 | enabled: true
24 |
25 | Flay:
26 | enabled: true
27 |
28 | Pronto:
29 | enabled: true
30 |
31 | RailsBestPractices:
32 | enabled: true
33 |
34 | Reek:
35 | enabled: true
36 |
37 | RuboCop:
38 | enabled: true
39 | on_warn: fail # Treat all warnings as failures
40 |
41 | RubyLint:
42 | enabled: false
43 |
44 | RubySyntax:
45 | enabled: true
46 |
47 | TrailingWhitespace:
48 | enabled: true
49 | exclude:
50 | - '**/db/structure.sql' # Ignore trailing whitespace in generated files
51 |
52 | #PostCheckout:
53 | # ALL: # Special hook name that customizes all hooks of this type
54 | # quiet: true # Change all post-checkout hooks to only display output on failure
55 | #
56 | # IndexTags:
57 | # enabled: true # Generate a tags file with `ctags` each time HEAD changes
58 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.tar.gz
2 | *.gem
3 | *.rbc
4 | /.config
5 | /.cache
6 | /coverage/
7 | /InstalledFiles
8 | /pkg/
9 | /spec/reports/
10 | /spec/examples.txt
11 | /test/tmp/
12 | /test/version_tmp/
13 | /tmp/
14 | /log/
15 | /logs/**/*.log
16 | /public/
17 |
18 | # Used by dotenv library to load environment variables.
19 | .env.local
20 | .env.*.local
21 |
22 | ## Specific to RubyMotion:
23 | .dat*
24 | .repl_history
25 | build/
26 | *.bridgesupport
27 | build-iPhoneOS/
28 | build-iPhoneSimulator/
29 |
30 | ## Specific to RubyMotion (use of CocoaPods):
31 | #
32 | # We recommend against adding the Pods directory to your .gitignore. However
33 | # you should judge for yourself, the pros and cons are mentioned at:
34 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
35 | #
36 | # vendor/Pods/
37 |
38 | ## Documentation cache and generated files:
39 | /.yardoc/
40 | /_yardoc/
41 | /doc/
42 | /rdoc/
43 |
44 | ## Environment normalization:
45 | /.bundle/
46 | /vendor/bundle
47 | /lib/bundler/man/
48 |
49 | # for a library or gem, you might want to ignore these files since the code is
50 | # intended to run in multiple environments; otherwise, check them in:
51 | # Gemfile.lock
52 | # .ruby-version
53 | # .ruby-gemset
54 |
55 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
56 | .rvmrc
57 |
58 | # IDE files
59 | .idea
60 |
61 | ## Gem files
62 | .ruby-version
63 | .ruby-gemset
64 |
65 | ## Temp files
66 | .byebug_history
67 | .ash_history
68 | .irb_history
69 | html/
70 |
71 | # Ignore master key for decrypting credentials and more.
72 | /config/master.key
73 |
74 | # SSL Certificates
75 | *.key
76 | *.pem
77 |
78 | # Webpacker files
79 | /public/packs
80 | /public/packs-test
81 | /node_modules
82 | /yarn-error.log
83 | yarn-debug.log*
84 | .yarn-integrity
85 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | *.tar.gz
2 | *.gem
3 | *.rbc
4 | /coverage/
5 | /InstalledFiles
6 | /pkg/
7 | /spec/reports/
8 | /spec/examples.txt
9 | /test/tmp/
10 | /test/version_tmp/
11 | /tmp/
12 | /log/
13 | /logs/**/*.log
14 | /public/
15 | /storage/
16 |
17 | # CI and code analysis tools
18 | /.github/
19 | .overcommit.yml
20 | .rubocop.yml
21 |
22 | # Used by dotenv library to load environment variables.
23 | .env.local
24 | .env.*
25 |
26 | ## Specific to RubyMotion:
27 | .dat*
28 | .repl_history
29 | build/
30 | *.bridgesupport
31 | build-iPhoneOS/
32 | build-iPhoneSimulator/
33 |
34 | ## Specific to RubyMotion (use of CocoaPods):
35 | #
36 | # We recommend against adding the Pods directory to your .gitignore. However
37 | # you should judge for yourself, the pros and cons are mentioned at:
38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
39 | #
40 | # vendor/Pods/
41 |
42 | ## Documentation cache and generated files:
43 | /.yardoc/
44 | /_yardoc/
45 | /doc/
46 | /rdoc/
47 |
48 | ## Environment normalization:
49 | /.bundle/
50 | /vendor/bundle
51 | /lib/bundler/man/
52 |
53 | # for a library or gem, you might want to ignore these files since the code is
54 | # intended to run in multiple environments; otherwise, check them in:
55 | # Gemfile.lock
56 | # .ruby-version
57 | # .ruby-gemset
58 |
59 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
60 | .rvmrc
61 |
62 | # IDE files
63 | .idea
64 |
65 | ## Gem files
66 | .ruby-version
67 | .ruby-gemset
68 |
69 | ## Temp files
70 | .byebug_history
71 | .ash_history
72 | .irb_history
73 | html/
74 |
75 | ## Docker-specific files:
76 | Dockerfile
77 | Dockerfile.prod
78 | .dockerignore
79 | docker-compose.yml
80 | docker-compose.prod.yml
81 | .git
82 |
83 | # RSpec
84 | /spec/
85 |
86 | # Terraform
87 | /terraform/
88 |
--------------------------------------------------------------------------------
/app/controllers/relationships_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RelationshipsController < ProjectsController
4 | def create
5 | @relationship = Edge.new(graph: project.graph)
6 |
7 | from = Task.find(project.graph, relationship_params[:from_id])
8 | to = Task.find(project.graph, relationship_params[:to_id])
9 |
10 | relationship.update(**Task.invert(from: from, type: relationship_params[:type], to: to))
11 |
12 | respond_to do |format|
13 | format.turbo_stream do
14 | render turbo_stream: turbo_stream.replace("task_form", partial: "tasks/form", locals: { project: project, task: task })
15 | end
16 | end
17 | end
18 |
19 | def destroy
20 | relationship.destroy
21 |
22 | respond_to do |format|
23 | format.turbo_stream do
24 | render turbo_stream: turbo_stream.replace("task_form", partial: "tasks/form", locals: { project: project, task: task })
25 | end
26 | end
27 | end
28 |
29 | private
30 |
31 | def task
32 | @task ||= Task.find(project.graph, relationship_params[:task_id]) if relationship_params[:task_id]
33 | end
34 |
35 | def relationship
36 | @relationship ||= find_relationship
37 | end
38 |
39 | def find_relationship
40 | return unless relationship_params[:from_id] && relationship_params[:type] && relationship_params[:to_id]
41 |
42 | from = Task.find(project.graph, relationship_params[:from_id])
43 | to = Task.find(project.graph, relationship_params[:to_id])
44 |
45 | Edge.find(project.graph, **Task.invert(from: from, type: relationship_params[:type], to: to))
46 | end
47 |
48 | def relationship_params
49 | params
50 | .require(:relationship)
51 | .permit(
52 | :task_id,
53 | :from_id,
54 | :to_id,
55 | :type,
56 | )
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/app/views/tasks/index.html.erb:
--------------------------------------------------------------------------------
1 | <%= turbo_frame_tag "task" %>
2 |
3 |
4 | <% if project.tasks.empty? %>
5 |
Nothing here
6 | <% end %>
7 |
8 |
9 | <%= link_to new_project_task_path(project_id: project), class: "m-8", title: "Create new task...", data: { action: "click->task-modal#open", "turbo-frame": "task" } do %>
10 |
11 | <%= heroicon "plus", options: { class: "w-6 h-6" } %>
12 |
13 | <% end %>
14 |
15 |
16 |
17 | <%= render partial: "tasks/modal_form" %>
18 |
19 |
20 |
21 |
22 |
23 | <%= heroicon "zoom-in", options: { class: "w-6 h-6" } %>
24 |
25 |
26 | <%= heroicon "globe-alt", options: { class: "w-6 h-6" } %>
27 |
28 |
29 | <%= heroicon "zoom-out", options: { class: "w-6 h-6" } %>
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
We're sorry, but something went wrong.
62 |
63 |
If you are the application owner check the logs for more information.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | require:
2 | - rubocop-performance
3 | - rubocop-rails
4 | - rubocop-rspec
5 |
6 | AllCops:
7 | NewCops: enable
8 | Exclude:
9 | - bin/*
10 | - tmp/**/*
11 | - vendor/**/*
12 | - db/schema.rb
13 | - node_modules/**/*
14 | TargetRubyVersion: 3.0
15 |
16 | Layout/LineLength:
17 | Enabled: false
18 |
19 | Layout/MultilineMethodCallIndentation:
20 | EnforcedStyle: indented
21 |
22 | Layout/ParameterAlignment:
23 | EnforcedStyle: with_fixed_indentation
24 |
25 | Lint/AssignmentInCondition:
26 | AllowSafeAssignment: true
27 |
28 | Lint/SafeNavigationWithEmpty:
29 | Enabled: false
30 |
31 | Metrics/AbcSize:
32 | Max: 30
33 | Exclude:
34 | - db/migrate/*
35 |
36 | Metrics/BlockLength:
37 | Enabled: false
38 |
39 | Metrics/CyclomaticComplexity:
40 | Enabled: false
41 |
42 | Metrics/MethodLength:
43 | Max: 20
44 | Exclude:
45 | - db/migrate/*
46 | - lib/tasks/*
47 |
48 | Metrics/PerceivedComplexity:
49 | Enabled: false
50 |
51 | Rails:
52 | Enabled: true
53 |
54 | Rails/EnvironmentVariableAccess:
55 | Enabled: false
56 |
57 | Rails/Output:
58 | Exclude:
59 | - db/seeds/**/*
60 |
61 | RSpec/ExampleLength:
62 | Enabled: false
63 |
64 | RSpec/FilePath:
65 | Enabled: false
66 |
67 | RSpec/MultipleExpectations:
68 | Enabled: false
69 |
70 | Style/StringLiterals:
71 | EnforcedStyle: double_quotes
72 |
73 | Style/TrailingCommaInArguments:
74 | EnforcedStyleForMultiline: consistent_comma
75 |
76 | Style/TrailingCommaInArrayLiteral:
77 | EnforcedStyleForMultiline: consistent_comma
78 |
79 | Style/TrailingCommaInHashLiteral:
80 | EnforcedStyleForMultiline: consistent_comma
81 |
82 | Style/Documentation:
83 | Enabled: false
84 |
85 | Style/SymbolArray:
86 | EnforcedStyle: brackets
87 |
88 | Style/PercentLiteralDelimiters:
89 | PreferredDelimiters:
90 | default: ()
91 | "%i": "()"
92 | "%I": "()"
93 | "%r": "()"
94 | "%w": "()"
95 | "%W": "()"
96 |
--------------------------------------------------------------------------------
/config/puma.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Puma can serve each request in a thread from an internal thread pool.
4 | # The `threads` method setting takes two numbers: a minimum and maximum.
5 | # Any libraries that use thread pools should be configured to match
6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
7 | # and maximum; this matches the default thread size of Active Record.
8 | #
9 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
10 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
11 | threads min_threads_count, max_threads_count
12 |
13 | # Specifies the `worker_timeout` threshold that Puma will use to wait before
14 | # terminating a worker in development environments.
15 | #
16 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
17 |
18 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
19 | #
20 | port ENV.fetch("PORT", 3000)
21 |
22 | # Specifies the `environment` that Puma will run in.
23 | #
24 | environment ENV.fetch("RAILS_ENV", "development")
25 |
26 | # Specifies the `pidfile` that Puma will use.
27 | pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid")
28 |
29 | # Specifies the number of `workers` to boot in clustered mode.
30 | # Workers are forked web server processes. If using threads and workers together
31 | # the concurrency of the application would be max `threads` * `workers`.
32 | # Workers do not work on JRuby or Windows (both of which do not support
33 | # processes).
34 |
35 | workers ENV.fetch("WEB_CONCURRENCY", 2)
36 |
37 | # Use the `preload_app!` method when specifying a `workers` number.
38 | # This directive tells Puma to first boot the application and load code
39 | # before forking the application. This takes advantage of Copy On Write
40 | # process behavior so workers use less memory.
41 | #
42 | # preload_app!
43 |
44 | # Allow puma to be restarted by `rails restart` command.
45 | plugin :tmp_restart
46 |
--------------------------------------------------------------------------------
/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The change you wanted was rejected.
62 |
Maybe you tried to change something you didn't have access to.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The page you were looking for doesn't exist.
62 |
You may have mistyped the address or the page may have moved.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | var validEnv = ['development', 'test', 'production']
3 | var currentEnv = api.env()
4 | var isDevelopmentEnv = api.env('development')
5 | var isProductionEnv = api.env('production')
6 | var isTestEnv = api.env('test')
7 |
8 | if (!validEnv.includes(currentEnv)) {
9 | throw new Error(
10 | 'Please specify a valid `NODE_ENV` or ' +
11 | '`BABEL_ENV` environment variables. Valid values are "development", ' +
12 | '"test", and "production". Instead, received: ' +
13 | JSON.stringify(currentEnv) +
14 | '.'
15 | )
16 | }
17 |
18 | return {
19 | presets: [
20 | isTestEnv && [
21 | '@babel/preset-env',
22 | {
23 | targets: {
24 | node: 'current'
25 | }
26 | }
27 | ],
28 | (isProductionEnv || isDevelopmentEnv) && [
29 | '@babel/preset-env',
30 | {
31 | forceAllTransforms: true,
32 | useBuiltIns: 'entry',
33 | corejs: 3,
34 | modules: false,
35 | exclude: ['transform-typeof-symbol']
36 | }
37 | ]
38 | ].filter(Boolean),
39 | plugins: [
40 | 'babel-plugin-macros',
41 | '@babel/plugin-syntax-dynamic-import',
42 | isTestEnv && 'babel-plugin-dynamic-import-node',
43 | '@babel/plugin-transform-destructuring',
44 | [
45 | '@babel/plugin-proposal-class-properties',
46 | {
47 | loose: true
48 | }
49 | ],
50 | [
51 | '@babel/plugin-proposal-object-rest-spread',
52 | {
53 | useBuiltIns: true
54 | }
55 | ],
56 | [
57 | '@babel/plugin-transform-runtime',
58 | {
59 | helpers: false
60 | }
61 | ],
62 | [
63 | '@babel/plugin-transform-regenerator',
64 | {
65 | async: false
66 | }
67 | ],
68 | [
69 | '@babel/plugin-proposal-private-methods',
70 | {
71 | 'loose': true,
72 | },
73 | ]
74 | ].filter(Boolean)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Deployment
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'production'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | app:
11 | name: Deploy application
12 | runs-on: ubuntu-20.04
13 | environment: production
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 |
18 | - name: Login to GitHub Container Registry
19 | uses: docker/login-action@v1
20 | with:
21 | registry: ghcr.io
22 | username: ${{ github.repository_owner }}
23 | password: ${{ secrets.DOCKER_TOKEN }}
24 |
25 | - name: Copy files
26 | uses: appleboy/scp-action@master
27 | with:
28 | host: ${{ secrets.SSH_HOST }}
29 | username: ${{ secrets.SSH_USER }}
30 | key: ${{ secrets.SSH_KEY }}
31 | source: ops/docker-compose.yml
32 | target: $HOME
33 | strip_components: 1
34 |
35 | - name: Set environment
36 | run: |
37 | echo "SECRET_KEY_BASE=${{ secrets.SECRET_KEY_BASE }}" >> $GITHUB_ENV
38 | echo "GANDIV5_API_KEY=${{ secrets.GANDIV5_API_KEY }}" >> $GITHUB_ENV
39 |
40 | - name: Deploy application
41 | uses: appleboy/ssh-action@master
42 | env:
43 | GITHUB_USER: ${{ github.repository_owner }}
44 | GITHUB_TOKEN: ${{ secrets.DOCKER_TOKEN }}
45 | GANDIV5_API_KEY: ${{ secrets.GANDIV5_API_KEY }}
46 | SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}
47 | with:
48 | host: ${{ secrets.SSH_HOST }}
49 | username: ${{ secrets.SSH_USER }}
50 | key: ${{ secrets.SSH_KEY }}
51 | script_stop: true
52 | envs: GITHUB_USER,GITHUB_TOKEN,GANDIV5_API_KEY,SECRET_KEY_BASE
53 | script: |
54 | echo GANDIV5_API_KEY=${GANDIV5_API_KEY} > .env
55 | echo SECRET_KEY_BASE=${SECRET_KEY_BASE} >> .env
56 | echo "Pulling latest images"
57 | docker login https://ghcr.io -u ${GITHUB_USER} -p ${GITHUB_TOKEN}
58 | docker-compose pull --quiet
59 | echo "Recreating containers"
60 | docker-compose up --detach --remove-orphans
61 |
--------------------------------------------------------------------------------
/config/webpacker.yml:
--------------------------------------------------------------------------------
1 | # Note: You must restart bin/webpack-dev-server for changes to take effect
2 |
3 | default: &default
4 | source_path: app/javascript
5 | source_entry_path: packs
6 | public_root_path: public
7 | public_output_path: packs
8 | cache_path: tmp/cache/webpacker
9 | webpack_compile_output: true
10 |
11 | # Additional paths webpack should lookup modules
12 | # ['app/assets', 'engine/foo/app/assets']
13 | additional_paths: []
14 |
15 | # Reload manifest.json on all requests so we reload latest compiled packs
16 | cache_manifest: false
17 |
18 | # Extract and emit a css file
19 | extract_css: false
20 |
21 | static_assets_extensions:
22 | - .jpg
23 | - .jpeg
24 | - .png
25 | - .gif
26 | - .tiff
27 | - .ico
28 | - .svg
29 | - .eot
30 | - .otf
31 | - .ttf
32 | - .woff
33 | - .woff2
34 |
35 | extensions:
36 | - .mjs
37 | - .js
38 | - .sass
39 | - .scss
40 | - .css
41 | - .module.sass
42 | - .module.scss
43 | - .module.css
44 | - .png
45 | - .svg
46 | - .gif
47 | - .jpeg
48 | - .jpg
49 |
50 | development:
51 | <<: *default
52 | compile: true
53 |
54 | # Reference: https://webpack.js.org/configuration/dev-server/
55 | dev_server:
56 | https: false
57 | host: localhost
58 | port: 3035
59 | public: localhost:3035
60 | hmr: false
61 | # Inline should be set to true if using HMR
62 | inline: true
63 | overlay: true
64 | compress: true
65 | disable_host_check: true
66 | use_local_ip: false
67 | quiet: false
68 | pretty: false
69 | headers:
70 | 'Access-Control-Allow-Origin': '*'
71 | watch_options:
72 | ignored: '**/node_modules/**'
73 |
74 |
75 | test:
76 | <<: *default
77 | compile: true
78 |
79 | # Compile test packs to a separate directory
80 | public_output_path: packs-test
81 |
82 | production:
83 | <<: *default
84 |
85 | # Production depends on precompilation of packs prior to booting for performance.
86 | compile: false
87 |
88 | # Extract and emit a css file
89 | extract_css: false
90 |
91 | # Cache manifest.json for performance
92 | cache_manifest: true
93 |
--------------------------------------------------------------------------------
/app/views/calendar/show.html.erb:
--------------------------------------------------------------------------------
1 | <%= turbo_frame_tag "calendar" do %>
2 |
3 |
4 | <%= date.strftime("%B") %>
5 | <%= date.strftime("%Y") %>
6 |
7 |
8 | <%= link_to calendar_path(project_id: project.id, task_id: task&.id, year: past.year, month: past.month),
9 | data: { turbo_frame: "calendar" },
10 | class: "p-2 rounded-full inline-flex items-center transition text-gray-400 hover:bg-gray-200" do %>
11 | <%= heroicon "chevron-left", options: { class: "h-4 w-4 ml-auto inline-block" } %>
12 | <% end %>
13 | <%= link_to calendar_path(project_id: project.id, task_id: task&.id, year: future.year, month: future.month),
14 | data: { turbo_frame: "calendar" },
15 | class: "p-2 rounded-full inline-flex items-center transition text-gray-400 hover:bg-gray-200" do %>
16 | <%= heroicon "chevron-right", options: { class: "h-4 w-4 ml-auto inline-block" } %>
17 | <% end %>
18 |
19 |
20 |
21 |
Mon
22 |
Tue
23 |
Wed
24 |
Thu
25 |
Fri
26 |
Sat
27 |
Sun
28 |
29 | <% (date.wday - 1).times do %>
30 |
31 | <% end %>
32 |
33 | <% (date.beginning_of_month..date.end_of_month).each do |date| %>
34 |
hover:bg-gray-200 <%= "bg-red-600 text-white hover:bg-red-700" if date == task&.deadline %>"
36 | data-action="click->task-dropdown#toggle click->task-dropdown#set"
37 | data-value="<%= date.iso8601 unless date == task&.deadline %>"
38 | data-title="<%= date == task&.deadline ? "No deadline" : I18n.l(date, format: :long) %>"
39 | >
40 | <%= date.day %>
41 |
42 | <% end %>
43 |
44 | <% end %>
45 |
--------------------------------------------------------------------------------
/lib/tasks/auto_annotate_models.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # NOTE: only doing this in development as some production environments (Heroku)
4 | # NOTE: are sensitive to local FS writes, and besides -- it's just not proper
5 | # NOTE: to have a dev-mode tool do its thing in production.
6 | if Rails.env.development?
7 | require "annotate"
8 | task set_annotation_options: :environment do
9 | # You can override any of these by setting an environment variable of the
10 | # same name.
11 | Annotate.set_defaults(
12 | "active_admin" => "false",
13 | "additional_file_patterns" => [],
14 | "routes" => "true",
15 | "models" => "true",
16 | "position_in_routes" => "after",
17 | "position_in_class" => "after",
18 | "position_in_test" => "before",
19 | "position_in_fixture" => "before",
20 | "position_in_factory" => "before",
21 | "position_in_serializer" => "before",
22 | "show_foreign_keys" => "true",
23 | "show_complete_foreign_keys" => "false",
24 | "show_indexes" => "true",
25 | "simple_indexes" => "false",
26 | "model_dir" => "app/models",
27 | "root_dir" => "",
28 | "include_version" => "false",
29 | "require" => "",
30 | "exclude_tests" => "true",
31 | "exclude_fixtures" => "true",
32 | "exclude_factories" => "true",
33 | "exclude_serializers" => "true",
34 | "exclude_scaffolds" => "true",
35 | "exclude_controllers" => "true",
36 | "exclude_helpers" => "true",
37 | "exclude_sti_subclasses" => "false",
38 | "ignore_model_sub_dir" => "false",
39 | "ignore_columns" => nil,
40 | "ignore_routes" => nil,
41 | "ignore_unknown_models" => "false",
42 | "hide_limit_column_types" => "integer,bigint,boolean",
43 | "hide_default_column_types" => "json,jsonb,hstore",
44 | "skip_on_db_migrate" => "false",
45 | "format_bare" => "true",
46 | "format_rdoc" => "false",
47 | "format_yard" => "false",
48 | "format_markdown" => "false",
49 | "sort" => "false",
50 | "force" => "false",
51 | "frozen" => "false",
52 | "classified_sort" => "true",
53 | "trace" => "false",
54 | "wrapper_open" => nil,
55 | "wrapper_close" => nil,
56 | "with_comment" => "true",
57 | )
58 | end
59 |
60 | Annotate.load_tasks
61 | end
62 |
--------------------------------------------------------------------------------
/app/graph/concerns/persistence/node.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Persistence
4 | module Node
5 | extend ActiveSupport::Concern
6 |
7 | included do
8 | define_model_callbacks :destroy, :save
9 |
10 | def destroy
11 | run_callbacks :destroy do
12 | return false unless !destroyed? && persisted?
13 |
14 | graph
15 | .match(:n, self.class.name, id: id)
16 | .delete(:n)
17 | .execute
18 |
19 | @_destroyed = true
20 | @_persisted = false
21 |
22 | freeze
23 |
24 | true
25 | end
26 | end
27 |
28 | def destroyed?
29 | !!@_destroyed
30 | end
31 |
32 | def new_record?
33 | !persisted?
34 | end
35 |
36 | def persisted?
37 | !!@_persisted
38 | end
39 |
40 | def persist!
41 | @_persisted = true
42 | end
43 |
44 | def reload
45 | return false unless persisted?
46 |
47 | assign_attributes graph
48 | .match(:n, self.class.name, id: id)
49 | .return(:n)
50 | .first
51 | &.fetch(:n)
52 |
53 | true
54 | end
55 |
56 | def save
57 | run_callbacks :save do
58 | return false unless valid?
59 |
60 | self.id ||= SecureRandom.uuid
61 |
62 | graph
63 | .merge(:n, self.class.name, id: id)
64 | .set(**attributes.except(:graph))
65 | .execute
66 |
67 | @_persisted = true
68 | end
69 | end
70 |
71 | def update(attributes)
72 | assign_attributes(attributes)
73 |
74 | save
75 | end
76 |
77 | def ==(other)
78 | other.instance_of?(self.class) &&
79 | persisted? &&
80 | other.id == id
81 | end
82 | end
83 |
84 | class_methods do
85 | def load(...)
86 | new(...).tap(&:persist!)
87 | end
88 |
89 | def find(graph, id)
90 | attributes = graph
91 | .match(:n, name, id: id)
92 | .return(:n)
93 | .first
94 | &.fetch(:n)
95 | &.merge(graph: graph)
96 |
97 | return unless attributes
98 |
99 | load(attributes)
100 | end
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/app/graph/renderer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Renderer
4 | include TaskHelper
5 |
6 | attr_reader :project
7 |
8 | def initialize(project)
9 | @project = project
10 | end
11 |
12 | def to_h
13 | {
14 | nodes: nodes,
15 | edges: edges,
16 | groups: groups,
17 | constraints: constraints,
18 | }
19 | end
20 |
21 | private
22 |
23 | def nodes
24 | @nodes ||= tasks_by_type.values.flatten.map do |task|
25 | {
26 | id: task.id,
27 | label: task.title,
28 | icon: icon_for(task.type),
29 | color: color_for_type(task.type),
30 | type: task.type.titleize,
31 | status: color_for_status(task.status),
32 | }
33 | end
34 | end
35 |
36 | def edges
37 | @edges ||= tasks.flat_map do |task|
38 | task.relationships.select { |_k, v| v[:inverse_of].nil? }.keys.flat_map do |relationship_type|
39 | task.send(relationship_type).map do |node|
40 | {
41 | source: nodes.find_index { |n| n[:id] == task.id },
42 | target: nodes.find_index { |n| n[:id] == node.id },
43 | label: relationship_type.to_s.titleize,
44 | }
45 | end
46 | end.reject(&:blank?)
47 | end
48 | end
49 |
50 | def groups
51 | @groups ||= tasks_by_type.fetch("feature", []).filter_map do |task|
52 | children = task.parent_of.map { |n| tasks.index(n) }
53 |
54 | { leaves: children } if children.present?
55 | end
56 | end
57 |
58 | def constraints
59 | @constraints ||= tasks_by_type.each_cons(2).flat_map do |(_type, nodes), (_subtype, subnodes)|
60 | nodes.flat_map do |node|
61 | subnodes.map do |subnode|
62 | {
63 | axis: "y",
64 | left: tasks.index(node),
65 | right: tasks.index(subnode),
66 | gap: 150,
67 | }
68 | end
69 | end
70 | end
71 | end
72 |
73 | def icon_for(type)
74 | # Extract from SVG icon
75 | Heroicon::Icon
76 | .render(name: icon_for_type(type), variant: :outline, options: {})
77 | .css("path")
78 | .to_s
79 | end
80 |
81 | def tasks_by_type
82 | @tasks_by_type ||= project
83 | .graph
84 | .tasks
85 | .group_by(&:type)
86 | .sort_by { |k, _v| Task::TYPES.index(k) }
87 | .to_h
88 | end
89 |
90 | def tasks
91 | @tasks ||= tasks_by_type
92 | .values
93 | .flatten
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/Dockerfile.prod:
--------------------------------------------------------------------------------
1 | FROM ruby:3.0-alpine AS ruby
2 |
3 | WORKDIR /app/
4 |
5 | # Install system dependencies
6 | RUN apk add --no-cache build-base curl-dev git postgresql-dev cmake
7 |
8 | # Install Bundler
9 | RUN gem update --system && gem install bundler
10 |
11 | # Install Gem dependencies
12 | ADD Gemfile /app/
13 | ADD Gemfile.lock /app/
14 |
15 | RUN bundle config set --local without "development test" && \
16 | bundle config set --local jobs 4 && \
17 | bundle config set --local deployment true && \
18 | bundle install
19 |
20 | FROM ruby AS node
21 |
22 | # Install Yarn
23 | RUN apk add --no-cache nodejs yarn
24 |
25 | # Install NPM dependencies
26 | ADD package.json /app/
27 | ADD yarn.lock /app/
28 |
29 | RUN yarn install
30 |
31 | FROM node AS assets
32 |
33 | # Only add files that affect the assets:precompile task
34 | ADD Rakefile /app/Rakefile
35 | ADD postcss.config.js /app/postcss.config.js
36 | ADD config/application.rb /app/config/application.rb
37 | ADD config/boot.rb /app/config/boot.rb
38 | ADD config/environment.rb /app/config/environment.rb
39 | ADD config/environments/production.rb /app/config/environments/production.rb
40 | ADD config/initializers/assets.rb /app/config/initializers/assets.rb
41 | ADD config/locales /app/config/locales
42 | ADD config/webpacker.yml /app/config/webpacker.yml
43 | ADD config/webpack /app/config/webpack
44 | ADD app/assets /app/app/assets
45 | ADD app/javascript /app/app/javascript
46 | ADD bin/webpack /app/bin/webpack
47 |
48 | ARG SECRET_KEY_BASE=secret_key_base
49 | ARG RAILS_ENV=production
50 | ARG NODE_ENV=production
51 | RUN rake webpacker:compile
52 |
53 | FROM ruby
54 |
55 | ENV LC_ALL en_US.UTF-8
56 | ENV LANG en_US.UTF-8
57 |
58 | WORKDIR /app/
59 |
60 | # Add user
61 | ARG USER=docker
62 | ARG UID=1000
63 | ARG GID=1000
64 |
65 | RUN addgroup -g $GID $USER
66 | RUN adduser -D -u $UID -G $USER -h /app/ $USER
67 |
68 | # Install system dependencies
69 | RUN apk add --no-cache postgresql
70 |
71 | # Add application
72 | ADD . /app/
73 |
74 | # Copy assets
75 | COPY --from=assets /app/public/ /app/public/
76 |
77 | RUN mkdir -p /app/tmp/pids/
78 | RUN chown -R $UID:$GID /app/
79 |
80 | # Change user
81 | USER $USER
82 |
83 | EXPOSE 3000
84 |
85 | CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
86 |
--------------------------------------------------------------------------------
/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Code Red
5 |
6 |
7 | <%= csrf_meta_tags %>
8 | <%= csp_meta_tag %>
9 | <%= action_cable_meta_tag %>
10 |
11 | <%= javascript_pack_tag 'application', "data-turbo-track": "reload" %>
12 | <%= stylesheet_pack_tag 'application', "data-turbo-track": "reload" %>
13 |
14 | <%= turbo_include_tags %>
15 |
16 |
17 |
18 |
19 |
20 | <%= yield :head %>
21 |
22 |
23 |
24 | <%= render partial: "components/notifications" %>
25 |
26 |
27 |
28 |
29 | <%= image_pack_tag "logo.png", class: "w-20 h-20" %>
30 |
Code Red
31 |
32 |
33 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 | <%= yield %>
56 |
57 |
58 |
59 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/integer/time"
4 |
5 | # The test environment is used exclusively to run your application's
6 | # test suite. You never need to work with it otherwise. Remember that
7 | # your test database is "scratch space" for the test suite and is wiped
8 | # and recreated between test runs. Don't rely on the data there!
9 |
10 | Rails.application.configure do
11 | # Settings specified here will take precedence over those in config/application.rb.
12 |
13 | config.cache_classes = false
14 | config.action_view.cache_template_loading = true
15 |
16 | # Do not eager load code on boot. This avoids loading your whole application
17 | # just for the purpose of running a single test. If you are using a tool that
18 | # preloads Rails for running tests, you may have to set it to true.
19 | config.eager_load = false
20 |
21 | # Configure public file server for tests with Cache-Control for performance.
22 | config.public_file_server.enabled = true
23 | config.public_file_server.headers = {
24 | "Cache-Control" => "public, max-age=#{1.hour.to_i}",
25 | }
26 |
27 | # Show full error reports and disable caching.
28 | config.consider_all_requests_local = true
29 | config.action_controller.perform_caching = true
30 | config.cache_store = :memory_store
31 |
32 | # Raise exceptions instead of rendering exception templates.
33 | config.action_dispatch.show_exceptions = false
34 |
35 | # Disable request forgery protection in test environment.
36 | config.action_controller.allow_forgery_protection = false
37 |
38 | # Store uploaded files on the local file system in a temporary directory.
39 | config.active_storage.service = :test
40 |
41 | config.action_mailer.perform_caching = false
42 |
43 | # Tell Action Mailer not to deliver emails to the real world.
44 | # The :test delivery method accumulates sent emails in the
45 | # ActionMailer::Base.deliveries array.
46 | config.action_mailer.delivery_method = :test
47 |
48 | # Print deprecation notices to the stderr.
49 | config.active_support.deprecation = :stderr
50 |
51 | # Raise exceptions for disallowed deprecations.
52 | config.active_support.disallowed_deprecation = :raise
53 |
54 | # Tell Active Support which deprecation messages to disallow.
55 | config.active_support.disallowed_deprecation_warnings = []
56 |
57 | # Raises error for missing translations.
58 | # config.i18n.raise_on_missing_translations = true
59 |
60 | # Annotate rendered view with file names.
61 | # config.action_view.annotate_rendered_view_with_filenames = true
62 | end
63 |
--------------------------------------------------------------------------------
/app/graph/concerns/persistence/edge.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Persistence
4 | module Edge
5 | extend ActiveSupport::Concern
6 |
7 | included do
8 | define_model_callbacks :destroy, :save
9 |
10 | def destroy
11 | run_callbacks :destroy do
12 | return false unless !destroyed? && persisted?
13 |
14 | graph
15 | .match(:n, from.class.name, id: from.id)
16 | .to(:r, type)
17 | .match(:m, to.class.name)
18 | .delete(:r)
19 | .execute
20 |
21 | @_destroyed = true
22 | @_persisted = false
23 |
24 | freeze
25 |
26 | true
27 | end
28 | end
29 |
30 | def destroyed?
31 | !!@_destroyed
32 | end
33 |
34 | def new_record?
35 | !persisted?
36 | end
37 |
38 | def persisted?
39 | !!@_persisted
40 | end
41 |
42 | def persist!
43 | @_persisted = true
44 | end
45 |
46 | def save
47 | run_callbacks :save do
48 | return false unless valid?
49 |
50 | graph
51 | .match(:n, from.class.name, id: from.id)
52 | .match(:m, to.class.name, id: to.id)
53 | .merge(:n)
54 | .to(:r, type)
55 | .merge(:m)
56 | .execute
57 |
58 | @_persisted = true
59 | end
60 | end
61 |
62 | def update(attributes)
63 | assign_attributes(attributes)
64 |
65 | save
66 | end
67 |
68 | def ==(other)
69 | other.instance_of?(self.class) &&
70 | persisted? &&
71 | type == other.type &&
72 | to == other.to &&
73 | from == other.from
74 | end
75 | end
76 |
77 | class_methods do
78 | def load(...)
79 | new(...).tap(&:persist!)
80 | end
81 |
82 | def where(graph, from: nil, type: nil, to: nil)
83 | graph
84 | .match(:n, from&.class&.name, **{ id: from&.id }.compact)
85 | .to(:r, type)
86 | .match(:m, to&.class&.name, **{ id: to&.id }.compact)
87 | .return(:n, :m, t: "type(r)")
88 | .map do |result|
89 | from = from.class.load(result[:n].merge(graph: graph))
90 | type = result[:t]
91 | to = to.class.load(result[:m].merge(graph: graph))
92 |
93 | load(graph: graph, from: from, type: type, to: to)
94 | end
95 | end
96 |
97 | def find(...)
98 | where(...).first
99 | end
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/app/graph/dsl.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DSL
4 | include Enumerable
5 |
6 | attr_reader :graph, :clauses
7 | attr_accessor :names
8 |
9 | # Clause target for `.to` method (:match or :merge)
10 | attr_writer :target
11 |
12 | def initialize(graph)
13 | @graph = graph
14 |
15 | @clauses = {
16 | match: Clause::Match.new,
17 | merge: Clause::Match.new,
18 | return: Clause::Return.new,
19 | delete: Clause.new,
20 | set: Clause.new,
21 | }
22 | @names = Set.new
23 | end
24 |
25 | def match(name, label = nil, **attributes)
26 | names << name
27 |
28 | label = ":#{label}" if label
29 | attributes = " {#{attributes.map { |k, v| "#{k}: '#{v}'" }.join(', ')}}" if attributes.any?
30 |
31 | clauses[:match] << "(#{name}#{label.presence}#{attributes.presence})"
32 |
33 | # Set clause target
34 | self.target = :match
35 |
36 | self
37 | end
38 |
39 | def merge(name, label = nil, **attributes)
40 | names << name
41 |
42 | label = ":#{label}" if label
43 | attributes = " {#{attributes.map { |k, v| "#{k}: '#{v}'" }.join(', ')}}" if attributes.any?
44 |
45 | clauses[:merge] << "(#{name}#{label.presence}#{attributes.presence})"
46 |
47 | # Set clause target
48 | self.target = :merge
49 |
50 | self
51 | end
52 |
53 | def to(name, label = nil)
54 | label = ":#{label}" if label
55 |
56 | clauses[target] << "-[#{name}#{label}]->"
57 |
58 | self
59 | end
60 |
61 | def return(*names, **aliases)
62 | self.names += names
63 | clauses[:return] += names
64 |
65 | self.names += aliases.keys
66 | clauses[:return] += aliases.map { |k, v| "#{v} AS #{k}" }
67 |
68 | self
69 | end
70 |
71 | def delete(*names)
72 | self.names += names
73 | clauses[:delete] << names.join(", ")
74 |
75 | # TODO: return node when deleting
76 |
77 | self
78 | end
79 |
80 | def set(**attributes)
81 | clauses[:set] << names.flat_map { |n| attributes.map { |k, v| "#{n}.#{k} = '#{v}'" } }.join(", ")
82 |
83 | self
84 | end
85 |
86 | def execute
87 | Rails.logger.debug "CYPHER #{graph.name} #{to_cypher}"
88 |
89 | result = graph
90 | .query(to_cypher)
91 | .resultset
92 |
93 | # TODO: check `stats` and return true/false
94 | return [] unless result
95 |
96 | result
97 | .map { |r| names.index_with.with_index { |_name, i| r[i].respond_to?(:each) ? r[i].reduce(&:merge).symbolize_keys : r[i] } }
98 | end
99 | delegate :each, to: :execute
100 |
101 | def empty?
102 | !any?
103 | end
104 |
105 | def to_cypher
106 | clauses
107 | .filter_map { |k, v| "#{k.upcase} #{v.to_query}" if v.present? }
108 | .join(" ")
109 | end
110 |
111 | private
112 |
113 | def target
114 | @target || raise(ArgumentError, "method `to` without preceding `match` or `merge` not allowed")
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/terraform/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/template" {
5 | version = "2.2.0"
6 | hashes = [
7 | "h1:94qn780bi1qjrbC3uQtjJh3Wkfwd5+tTtJHOb7KTg9w=",
8 | "zh:01702196f0a0492ec07917db7aaa595843d8f171dc195f4c988d2ffca2a06386",
9 | "zh:09aae3da826ba3d7df69efeb25d146a1de0d03e951d35019a0f80e4f58c89b53",
10 | "zh:09ba83c0625b6fe0a954da6fbd0c355ac0b7f07f86c91a2a97849140fea49603",
11 | "zh:0e3a6c8e16f17f19010accd0844187d524580d9fdb0731f675ffcf4afba03d16",
12 | "zh:45f2c594b6f2f34ea663704cc72048b212fe7d16fb4cfd959365fa997228a776",
13 | "zh:77ea3e5a0446784d77114b5e851c970a3dde1e08fa6de38210b8385d7605d451",
14 | "zh:8a154388f3708e3df5a69122a23bdfaf760a523788a5081976b3d5616f7d30ae",
15 | "zh:992843002f2db5a11e626b3fc23dc0c87ad3729b3b3cff08e32ffb3df97edbde",
16 | "zh:ad906f4cebd3ec5e43d5cd6dc8f4c5c9cc3b33d2243c89c5fc18f97f7277b51d",
17 | "zh:c979425ddb256511137ecd093e23283234da0154b7fa8b21c2687182d9aea8b2",
18 | ]
19 | }
20 |
21 | provider "registry.terraform.io/hetznercloud/hcloud" {
22 | version = "1.26.0"
23 | hashes = [
24 | "h1:2LLe4UKLS7R+t+tQL1oOFLA8c8/rs3iCfT26LyiQcsk=",
25 | "zh:03d7eb722a4ee25774949baace0125392060d0369d4cb9257d7d298ab6ece3ff",
26 | "zh:0fed2e63ac4cb6fe6b2a5b6891abf973cb7c1716e487fbabc09216e0ec05e866",
27 | "zh:1a84c8c1c8e2d6607de5aa09aa3f9254183cde75a5acc666cca5f4b02a1d290e",
28 | "zh:23ac426aa3a0001fb20045dc35569978864f139732f45ab671c64e80123c91a1",
29 | "zh:23b78348b24ae3e4679bd90989c999346efd71ee228d17368d5f556f63e5fd06",
30 | "zh:2503fe28ac87661af96e7755a7404307000822104ac1abc571271eee46c95ab5",
31 | "zh:3fe859b2611d20ed5cd65cc2ec812acf73c7dfb39f2fee45ef99a3896c2662a8",
32 | "zh:51ef869ed35d0d8aada35f587c4a64802f1140dc93c40a4e7c9800560143bb1a",
33 | "zh:69b93cf4adca465b89da08e4e3b4aaf831821f1fbae68e526c0a292b3cfa463d",
34 | "zh:6a4e23c6aa86e3d30240e6e4c97daef3af9ad217be2c6f35300fe1839fdbf8b2",
35 | "zh:97a513459692a981a62b4a566c1d736c4a67622d2fbbee3771ec3ea8d576d484",
36 | "zh:fec6c07731e23d1dd45015b44747b89c4fee58b5b2560f96d24c7da5a8ecb2ad",
37 | ]
38 | }
39 |
40 | provider "registry.terraform.io/psychopenguin/gandi" {
41 | version = "2.0.0-rc3"
42 | constraints = "2.0.0-rc3"
43 | hashes = [
44 | "h1:UtxuqLUMxLKprC9ZkHcFQHq/yeeKCAFrgydPb0Tadoo=",
45 | "zh:192367577f0a33a9ab063bab3858fcb78a3f9c7ef59a9bd49cfccd9d994f2439",
46 | "zh:24f260d0263d14da52da816d54945d317f07dd912960c9306cec466430014079",
47 | "zh:35a32a97484a4be5cc395db54751b6589da3e9f8d14ac6459d5d89d71735a577",
48 | "zh:4cbbd33df6aefc5a499f762645e26e09b7255b7d00a220233eb0bd1abee37942",
49 | "zh:9073e15d52eefc0d29123272b037a6d6d3ab36857bf28aebab75cf9527fb7055",
50 | "zh:a51359cb79a12a99666436f3d4e4e30045d4690d6fac02885c703df40ed38813",
51 | "zh:c22f625d89b6894164097cabd25a40038ed51ac07e7b1288816919e0f77fabf6",
52 | "zh:f50d81dbd7f4e0247385eb43ff680407918f6090bfb634ec255bae194ac52e88",
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/spec/graph/concerns/persistence/node_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Persistence::Node do
4 | subject(:node) { build(:node) }
5 |
6 | describe "#destroy" do
7 | it "destroys the node and returns true" do
8 | node.save
9 |
10 | expect(Node.find(node.graph, node.id)).not_to be_nil
11 |
12 | expect(node.destroy).to eq true
13 |
14 | expect(Node.find(node.graph, node.id)).to be_nil
15 | end
16 |
17 | it "returns false if the node was not destroyed" do
18 | expect(Node.find(node.graph, node.id)).to be_nil
19 |
20 | expect(node.destroy).to eq false
21 |
22 | expect(Node.find(node.graph, node.id)).to be_nil
23 | end
24 | end
25 |
26 | describe "#destroyed?" do
27 | it "returns true if the node was destroyed" do
28 | node.save
29 |
30 | node.destroy
31 |
32 | expect(node).to be_destroyed
33 | end
34 |
35 | it "returns false if the node was not destroyed" do
36 | node.destroy
37 |
38 | expect(node).not_to be_destroyed
39 | end
40 | end
41 |
42 | describe "#new_record?" do
43 | it "returns true if the record was not persisted yet" do
44 | expect(node).to be_new_record
45 | end
46 |
47 | it "returns false if the record was persisted" do
48 | node.save
49 |
50 | expect(node).not_to be_new_record
51 | end
52 | end
53 |
54 | describe "#persisted?" do
55 | it "returns false if the record was not persisted yet" do
56 | expect(node).not_to be_persisted
57 | end
58 |
59 | it "returns true if the record was persisted" do
60 | node.save
61 |
62 | expect(node).to be_persisted
63 | end
64 |
65 | it "returns false if the record was destroyed" do
66 | node.save
67 | node.destroy
68 |
69 | expect(node).not_to be_persisted
70 | end
71 | end
72 |
73 | describe "#reload" do
74 | it "reloads the attributes" do
75 | node.save
76 | node.created_at = nil
77 |
78 | node.reload
79 | expect(node.created_at).not_to be_nil
80 | end
81 | end
82 |
83 | describe "#save" do
84 | it "assigns a random id" do
85 | expect(node.id).to be_nil
86 |
87 | node.save
88 |
89 | expect(node.id).not_to be_nil
90 | end
91 |
92 | it "persists the node and returns true" do
93 | expect(Node.find(node.graph, node.id)).to be_nil
94 |
95 | expect(node.save).to eq true
96 |
97 | expect(Node.find(node.graph, node.id)).not_to be_nil
98 | end
99 |
100 | xit "returns false if the node was not persisted"
101 | end
102 |
103 | describe "#update" do
104 | xit "updates the attributes and returns true"
105 |
106 | xit "returns false if the node was not persisted"
107 | end
108 |
109 | describe ".find" do
110 | it "finds the node by id" do
111 | node.save
112 |
113 | found = Node.find(node.graph, node.id)
114 |
115 | expect(found).to eq node
116 | expect(found).to be_persisted
117 | end
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/spec/rails_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is copied to spec/ when you run 'rails generate rspec:install'
4 | require "spec_helper"
5 | ENV["RAILS_ENV"] = "test"
6 | require File.expand_path("../config/environment", __dir__)
7 | # Prevent database truncation if the environment is production
8 | abort("The Rails environment is running in production mode!") if Rails.env.production?
9 | require "rspec/rails"
10 | # Add additional requires below this line. Rails is not loaded until this point!
11 | require "webmock/rspec"
12 |
13 | # Requires supporting ruby files with custom matchers and macros, etc, in
14 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
15 | # run as spec files by default. This means that files in spec/support that end
16 | # in _spec.rb will both be required and run as specs, causing the specs to be
17 | # run twice. It is recommended that you do not name files matching this glob to
18 | # end with _spec.rb. You can configure this pattern with the --pattern
19 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
20 | #
21 | # The following line is provided for convenience purposes. It has the downside
22 | # of increasing the boot-up time by auto-requiring all files in the support
23 | # directory. Alternatively, in the individual `*_spec.rb` files, manually
24 | # require only the support files necessary.
25 |
26 | Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
27 |
28 | # Checks for pending migrations and applies them before tests are run.
29 | # If you are not using ActiveRecord, you can remove these lines.
30 | begin
31 | ActiveRecord::Migration.maintain_test_schema!
32 | rescue ActiveRecord::PendingMigrationError => e
33 | puts e.to_s.strip
34 | exit 1
35 | end
36 | RSpec.configure do |config|
37 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
38 | config.fixture_path = "#{::Rails.root}/spec/fixtures"
39 |
40 | # If you're not using ActiveRecord, or you'd prefer not to run each of your
41 | # examples within a transaction, remove the following line or assign false
42 | # instead of true.
43 | config.use_transactional_fixtures = true
44 |
45 | # You can uncomment this line to turn off ActiveRecord support entirely.
46 | # config.use_active_record = false
47 |
48 | # RSpec Rails can automatically mix in different behaviours to your tests
49 | # based on their file location, for example enabling you to call `get` and
50 | # `post` in specs under `spec/controllers`.
51 | #
52 | # You can disable this behaviour by removing the line below, and instead
53 | # explicitly tag your specs with their type, e.g.:
54 | #
55 | # RSpec.describe UsersController, type: :controller do
56 | # # ...
57 | # end
58 | #
59 | # The different available types are documented in the features, such as in
60 | # https://relishapp.com/rspec/rspec-rails/docs
61 | config.infer_spec_type_from_file_location!
62 |
63 | # Filter lines from Rails gems in backtraces.
64 | config.filter_rails_from_backtrace!
65 | # arbitrary gems may also be filtered via:
66 | # config.filter_gems_from_backtrace("gem name")
67 | end
68 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
5 |
6 | ruby "3.0.2"
7 |
8 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main'
9 | gem "rails", "~> 6.1.3", ">= 6.1.3.1"
10 | # Use postgresql as the database for Active Record
11 | gem "pg", ">= 0.18", "< 2.0"
12 | # Use Puma as the app server
13 | gem "puma", "~> 5.0"
14 | # Use SCSS for stylesheets
15 | gem "sass-rails", ">= 6"
16 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
17 | # gem 'jbuilder', '~> 2.7'
18 | # Use Redis adapter to run Action Cable in production
19 | gem "redis", "~> 4.0"
20 | # Use Active Model has_secure_password
21 | gem "bcrypt", "~> 3.1.7"
22 | # Use webpacker to manage assets
23 | gem "webpacker"
24 |
25 | # Redis Graph library
26 | gem "redisgraph", "~> 2.0"
27 |
28 | # Use Active Storage variant
29 | # gem 'image_processing', '~> 1.2'
30 |
31 | # Send HTML over the wire instead of JSON
32 | gem "hotwire-rails", "~> 0.1.3"
33 |
34 | # Send HTML over the wire instead of JSON
35 | gem "turbo-rails"
36 |
37 | # Hand-crafted SVG icons, by Tailwind
38 | gem "heroicon", "~> 0.3.0"
39 |
40 | # Reduces boot times through caching; required in config/boot.rb
41 | gem "bootsnap", ">= 1.4.4", require: false
42 |
43 | # Timezone data
44 | gem "tzinfo-data"
45 |
46 | group :development, :test do
47 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console
48 | gem "byebug"
49 | # gem "debase"
50 | # gem "ruby-debug-ide"
51 |
52 | # Database annotations
53 | # FIXME: revert to upstream gem when https://github.com/ctran/annotate_models/pull/843 is merged
54 | gem "annotate", github: "Vasfed/annotate_models", branch: "rails6_warning"
55 |
56 | # RuboCop
57 | gem "rubocop"
58 | gem "rubocop-performance"
59 | gem "rubocop-rails"
60 | gem "rubocop-rspec"
61 |
62 | # RSpec
63 | gem "rspec"
64 | gem "rspec-rails"
65 |
66 | # Shoulda-matchers
67 | gem "shoulda-matchers"
68 |
69 | # Time behaviour
70 | gem "timecop"
71 |
72 | # Factory testing pattern
73 | gem "factory_bot"
74 | gem "factory_bot_rails"
75 | gem "ffaker"
76 |
77 | # Mock HTTP requests
78 | gem "webmock"
79 | end
80 |
81 | group :development do
82 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
83 | gem "web-console", ">= 4.1.0"
84 | # Display performance information such as SQL time and flame graphs for each request in your browser.
85 | # Can be configured to work on production as well see: https://github.com/MiniProfiler/rack-mini-profiler/blob/master/README.md
86 | gem "listen", "~> 3.3"
87 | # gem "rack-mini-profiler", "~> 2.0"
88 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
89 | gem "spring"
90 |
91 | # Static code analysis tools
92 | gem "brakeman"
93 | gem "fasterer"
94 | gem "flay"
95 | gem "overcommit"
96 | gem "pronto"
97 | gem "rails_best_practices"
98 | gem "reek"
99 | end
100 |
--------------------------------------------------------------------------------
/terraform/default.tmpl.yml:
--------------------------------------------------------------------------------
1 | #cloud-config
2 |
3 | fqdn: ${fqdn}
4 | hostname: ${hostname}
5 | manage_etc_hosts: true
6 | timezone: Europe/Berlin
7 | locale: C.UTF-8
8 |
9 | ntp:
10 | enabled: true
11 |
12 | write_files:
13 | - path: /etc/network/interfaces.d/60-floating-ip
14 | content: |
15 | auto eth0:1
16 | iface eth0:1 inet static
17 | address ${ip_address}
18 | netmask 32
19 |
20 | users:
21 | - name: cloud
22 | gecos: Cloud User
23 | groups: docker
24 | # Generate a password using `mkpasswd --method=SHA-512 --rounds=4096`
25 | passwd: ${passwd}
26 | lock_passwd: false
27 | shell: /bin/bash
28 | sudo: ALL=(ALL) ALL
29 | ssh_authorized_keys:
30 | - ${chomp(ssh_public_key)}
31 |
32 | ssh_keys:
33 | rsa_public: ${chomp(sshd_public_key)}
34 | rsa_private: |
35 | ${indent(4, sshd_private_key)}
36 |
37 | mounts:
38 | - [ ${default_volume}, "/mnt/default/", "ext4", "defaults,discard", "0", "0" ]
39 | - [ "/mnt/default/docker/volumes/", "/var/lib/docker/volumes/", "none", "defaults,bind", "0", "0" ]
40 | - [ "/mnt/default/docker/swarm/", "/var/lib/docker/swarm/", "none", "defaults,bind", "0", "0" ]
41 |
42 | packages:
43 | - fail2ban
44 | - apt-transport-https
45 | - ca-certificates
46 | - curl
47 | - gnupg-agent
48 | - software-properties-common
49 | - jq
50 | - git
51 |
52 | package_update: true
53 | package_upgrade: true
54 | package_reboot_if_required: true
55 |
56 | runcmd:
57 | - printf "[sshd]\nenabled = true\nbanaction = iptables-multiport" > /etc/fail2ban/jail.local
58 | - systemctl enable --now fail2ban
59 | - systemctl restart networking
60 | - tune2fs -m 0 ${default_volume}
61 | - chown cloud:cloud /mnt/default
62 | - rmdir /mnt/default/lost+found
63 | # Configure SSH server
64 | - sed -i -e '/^#PermitRootLogin.*$/s/^.*$/PermitRootLogin no/' /etc/ssh/sshd_config
65 | - sed -i -e '/#^PasswordAuthentication/s/^.*$/PasswordAuthentication no/' /etc/ssh/sshd_config
66 | - sed -i -e '/^X11Forwarding/s/^.*$/X11Forwarding no/' /etc/ssh/sshd_config
67 | - sed -i -e '/^#MaxAuthTries/s/^.*$/MaxAuthTries 3/' /etc/ssh/sshd_config
68 | - sed -i -e '/^#AllowTcpForwarding/s/^.*$/AllowTcpForwarding no/' /etc/ssh/sshd_config
69 | - sed -i -e '/^#AllowAgentForwarding/s/^.*$/AllowAgentForwarding no/' /etc/ssh/sshd_config
70 | - sed -i -e '/^#AuthorizedKeysFile/s/^.*$/AuthorizedKeysFile .ssh\/authorized_keys/' /etc/ssh/sshd_config
71 | # Install Docker
72 | - "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -"
73 | - 'add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"'
74 | - apt-get update -y
75 | - apt-get install -y docker-ce docker-ce-cli containerd.io
76 | - systemctl enable --now docker
77 | # Install Docker Compose
78 | - "curl -L https://github.com/docker/compose/releases/download/$(curl -sL https://api.github.com/repos/docker/compose/releases/latest | jq -r .name)/docker-compose-Linux-x86_64 -o /usr/local/bin/docker-compose"
79 | - chmod +x /usr/local/bin/docker-compose
80 | # Reboot system
81 | - systemctl reboot
82 |
83 | final_message: "The system is finally up, after $UPTIME seconds"
84 |
--------------------------------------------------------------------------------
/spec/graph/concerns/persistence/edge_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Persistence::Edge do
4 | subject(:edge) { build(:edge) }
5 |
6 | describe "#destroy" do
7 | it "destroys the edge and returns true" do
8 | edge.save
9 |
10 | expect(Edge.where(edge.graph, from: edge.from, type: edge.type, to: edge.to)).not_to be_empty
11 |
12 | expect(edge.destroy).to eq true
13 |
14 | expect(Edge.where(edge.graph, from: edge.from, type: edge.type, to: edge.to)).to be_empty
15 | end
16 |
17 | it "returns false if the edge was not destroyed" do
18 | expect(Edge.where(edge.graph, from: edge.from, type: edge.type, to: edge.to)).to be_empty
19 |
20 | expect(edge.destroy).to eq false
21 |
22 | expect(Edge.where(edge.graph, from: edge.from, type: edge.type, to: edge.to)).to be_empty
23 | end
24 | end
25 |
26 | describe "#destroyed?" do
27 | it "returns true if the edge was destroyed" do
28 | edge.save
29 |
30 | edge.destroy
31 |
32 | expect(edge).to be_destroyed
33 | end
34 |
35 | it "returns false if the edge was not destroyed" do
36 | edge.destroy
37 |
38 | expect(edge).not_to be_destroyed
39 | end
40 | end
41 |
42 | describe "#new_record?" do
43 | it "returns true if the record was not persisted yet" do
44 | expect(edge).to be_new_record
45 | end
46 |
47 | it "returns false if the record was persisted" do
48 | edge.save
49 |
50 | expect(edge).not_to be_new_record
51 | end
52 | end
53 |
54 | describe "#persisted?" do
55 | it "returns false if the record was not persisted yet" do
56 | expect(edge).not_to be_persisted
57 | end
58 |
59 | it "returns true if the record was persisted" do
60 | edge.save
61 |
62 | expect(edge).to be_persisted
63 | end
64 |
65 | it "returns false if the record was destroyed" do
66 | edge.save
67 | edge.destroy
68 |
69 | expect(edge).not_to be_persisted
70 | end
71 | end
72 |
73 | describe "#save" do
74 | it "persists the edge and returns true" do
75 | expect(Edge.where(edge.graph, from: edge.from, type: edge.type, to: edge.to)).to be_empty
76 |
77 | expect(edge.save).to eq true
78 |
79 | expect(Edge.where(edge.graph, from: edge.from, type: edge.type, to: edge.to)).not_to be_empty
80 | end
81 |
82 | it "returns false if the edge was not persisted" do
83 | type = edge.type
84 | expect(Edge.where(edge.graph, from: edge.from, type: type, to: edge.to)).to be_empty
85 |
86 | edge.type = nil
87 | expect(edge.save).to eq false
88 |
89 | expect(Edge.where(edge.graph, from: edge.from, type: type, to: edge.to)).to be_empty
90 | end
91 | end
92 |
93 | describe ".where" do
94 | let(:graph) { build(:graph) }
95 |
96 | let(:task0) { create(:task, graph: graph) }
97 | let(:task1) { create(:task, graph: graph) }
98 | let!(:edge) { create(:edge, graph: graph, from: task0, type: "related_to", to: task1) }
99 |
100 | it "returns a node's edges" do
101 | expect(Edge.where(graph, from: task0, type: "related_to", to: task1)).to eq [edge]
102 | end
103 | end
104 | end
105 |
--------------------------------------------------------------------------------
/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/integer/time"
4 |
5 | Rails.application.configure do
6 | # Settings specified here will take precedence over those in config/application.rb.
7 |
8 | # In the development environment your application's code is reloaded any time
9 | # it changes. This slows down response time but is perfect for development
10 | # since you don't have to restart the web server when you make code changes.
11 | config.cache_classes = false
12 |
13 | # Do not eager load code on boot.
14 | config.eager_load = false
15 |
16 | # Show full error reports.
17 | config.consider_all_requests_local = true
18 |
19 | # Enable/disable caching. By default caching is disabled.
20 | # Run rails dev:cache to toggle caching.
21 | if Rails.root.join("tmp/caching-dev.txt").exist?
22 | config.action_controller.perform_caching = true
23 | config.action_controller.enable_fragment_cache_logging = true
24 |
25 | config.cache_store = :redis_cache_store, { url: ENV.fetch("REDIS_URL", "redis://redis:6379/1") }
26 | config.public_file_server.headers = {
27 | "Cache-Control" => "public, max-age=#{2.days.to_i}",
28 | }
29 | config.session_store :cache_store, key: "_sessions_development", compress: true, pool_size: 5, expire_after: 1.year
30 | else
31 | config.action_controller.perform_caching = false
32 |
33 | config.cache_store = :null_store
34 | end
35 |
36 | # Store uploaded files on the local file system (see config/storage.yml for options).
37 | config.active_storage.service = :local
38 |
39 | # Don't care if the mailer can't send.
40 | config.action_mailer.raise_delivery_errors = false
41 |
42 | config.action_mailer.perform_caching = false
43 |
44 | # Print deprecation notices to the Rails logger.
45 | config.active_support.deprecation = :log
46 |
47 | # Raise exceptions for disallowed deprecations.
48 | config.active_support.disallowed_deprecation = :raise
49 |
50 | # Tell Active Support which deprecation messages to disallow.
51 | config.active_support.disallowed_deprecation_warnings = []
52 |
53 | # Raise an error on page load if there are pending migrations.
54 | config.active_record.migration_error = :page_load
55 |
56 | # Highlight code that triggered database queries in logs.
57 | config.active_record.verbose_query_logs = true
58 |
59 | # Debug mode disables concatenation and preprocessing of assets.
60 | # This option may cause significant delays in view rendering with a large
61 | # number of complex assets.
62 | # config.assets.debug = true
63 |
64 | # Suppress logger output for asset requests.
65 | # config.assets.quiet = true
66 |
67 | # Raises error for missing translations.
68 | # config.i18n.raise_on_missing_translations = true
69 |
70 | # Annotate rendered view with file names.
71 | # config.action_view.annotate_rendered_view_with_filenames = true
72 |
73 | # Use an evented file watcher to asynchronously detect changes in source code,
74 | # routes, locales, etc. This feature depends on the listen gem.
75 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker
76 |
77 | # Uncomment if you wish to allow Action Cable access from any origin.
78 | # config.action_cable.disable_request_forgery_protection = true
79 | end
80 |
--------------------------------------------------------------------------------
/db/schema.rb:
--------------------------------------------------------------------------------
1 | # This file is auto-generated from the current state of the database. Instead
2 | # of editing this file, please use the migrations feature of Active Record to
3 | # incrementally modify your database, and then regenerate this schema definition.
4 | #
5 | # This file is the source Rails uses to define your schema when running `bin/rails
6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7 | # be faster and is potentially less error prone than running all of your
8 | # migrations from scratch. Old migrations may fail to apply correctly if those
9 | # migrations use external dependencies or application code.
10 | #
11 | # It's strongly recommended that you check this file into your version control system.
12 |
13 | ActiveRecord::Schema.define(version: 2021_05_11_164733) do
14 |
15 | # These are extensions that must be enabled in order to support this database
16 | enable_extension "pgcrypto"
17 | enable_extension "plpgsql"
18 |
19 | create_table "active_storage_attachments", force: :cascade do |t|
20 | t.string "name", null: false
21 | t.string "record_type", null: false
22 | t.uuid "record_id", null: false
23 | t.bigint "blob_id", null: false
24 | t.datetime "created_at", null: false
25 | t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
26 | t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
27 | end
28 |
29 | create_table "active_storage_blobs", force: :cascade do |t|
30 | t.string "key", null: false
31 | t.string "filename", null: false
32 | t.string "content_type"
33 | t.text "metadata"
34 | t.string "service_name", null: false
35 | t.bigint "byte_size", null: false
36 | t.string "checksum", null: false
37 | t.datetime "created_at", null: false
38 | t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
39 | end
40 |
41 | create_table "active_storage_variant_records", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
42 | t.bigint "blob_id", null: false
43 | t.string "variation_digest", null: false
44 | t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
45 | end
46 |
47 | create_table "projects", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
48 | t.string "name", null: false
49 | t.string "description"
50 | t.string "icon", default: "clipboard-list", null: false
51 | t.uuid "user_id"
52 | t.datetime "created_at", precision: 6, null: false
53 | t.datetime "updated_at", precision: 6, null: false
54 | t.index ["user_id"], name: "index_projects_on_user_id"
55 | end
56 |
57 | create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
58 | t.string "email", null: false
59 | t.string "name", null: false
60 | t.datetime "created_at", precision: 6, null: false
61 | t.datetime "updated_at", precision: 6, null: false
62 | t.index ["email"], name: "index_users_on_email"
63 | end
64 |
65 | add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
66 | add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
67 | add_foreign_key "projects", "users", on_delete: :nullify
68 | end
69 |
--------------------------------------------------------------------------------
/config/database.yml:
--------------------------------------------------------------------------------
1 | # PostgreSQL. Versions 9.3 and up are supported.
2 | #
3 | # Install the pg driver:
4 | # gem install pg
5 | # On macOS with Homebrew:
6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config
7 | # On macOS with MacPorts:
8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config
9 | # On Windows:
10 | # gem install pg
11 | # Choose the win32 build.
12 | # Install PostgreSQL and put its /bin directory on your path.
13 | #
14 | # Configure Using Gemfile
15 | # gem 'pg'
16 | #
17 | default: &default
18 | adapter: postgresql
19 | encoding: unicode
20 | # For details on connection pooling, see Rails configuration guide
21 | # https://guides.rubyonrails.org/configuring.html#database-pooling
22 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
23 | host: <%= ENV.fetch("PG_HOST", "postgres") %>
24 | username: <%= ENV.fetch("PG_USER", "postgres") %>
25 | password: <%= ENV.fetch("PG_PASSWORD", "postgres") %>
26 |
27 | development:
28 | <<: *default
29 | database: <%= ENV.fetch("PG_DATABASE", "codered_development") %>
30 |
31 | # The specified database role being used to connect to postgres.
32 | # To create additional roles in postgres see `$ createuser --help`.
33 | # When left blank, postgres will use the default role. This is
34 | # the same name as the operating system user that initialized the database.
35 | #username: codered
36 |
37 | # The password associated with the postgres role (username).
38 | #password:
39 |
40 | # Connect on a TCP socket. Omitted by default since the client uses a
41 | # domain socket that doesn't need configuration. Windows does not have
42 | # domain sockets, so uncomment these lines.
43 | #host: localhost
44 |
45 | # The TCP port the server listens on. Defaults to 5432.
46 | # If your server runs on a different port number, change accordingly.
47 | #port: 5432
48 |
49 | # Schema search path. The server defaults to $user,public
50 | #schema_search_path: myapp,sharedapp,public
51 |
52 | # Minimum log levels, in increasing order:
53 | # debug5, debug4, debug3, debug2, debug1,
54 | # log, notice, warning, error, fatal, and panic
55 | # Defaults to warning.
56 | #min_messages: notice
57 |
58 | # Warning: The database defined as "test" will be erased and
59 | # re-generated from your development database when you run "rake".
60 | # Do not set this db to the same as development or production.
61 | test:
62 | <<: *default
63 | database: <%= ENV.fetch("PG_DATABASE", "codered_test") %>
64 |
65 | # As with config/credentials.yml, you never want to store sensitive information,
66 | # like your database password, in your source code. If your source code is
67 | # ever seen by anyone, they now have access to your database.
68 | #
69 | # Instead, provide the password as a unix environment variable when you boot
70 | # the app. Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
71 | # for a full rundown on how to provide these environment variables in a
72 | # production deployment.
73 | #
74 | # On Heroku and other platform providers, you may have a full connection URL
75 | # available as an environment variable. For example:
76 | #
77 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
78 | #
79 | # You can use this database configuration with:
80 | #
81 | # production:
82 | # url: <%= ENV["DATABASE_URL"] %>
83 | #
84 | production:
85 | <<: *default
86 | database: <%= ENV.fetch("PG_DATABASE", "codered_production") %>
87 |
--------------------------------------------------------------------------------
/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'bundle' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require "rubygems"
12 |
13 | m = Module.new do
14 | module_function
15 |
16 | def invoked_as_script?
17 | File.expand_path($0) == File.expand_path(__FILE__)
18 | end
19 |
20 | def env_var_version
21 | ENV["BUNDLER_VERSION"]
22 | end
23 |
24 | def cli_arg_version
25 | return unless invoked_as_script? # don't want to hijack other binstubs
26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
27 | bundler_version = nil
28 | update_index = nil
29 | ARGV.each_with_index do |a, i|
30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
31 | bundler_version = a
32 | end
33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
34 | bundler_version = $1
35 | update_index = i
36 | end
37 | bundler_version
38 | end
39 |
40 | def gemfile
41 | gemfile = ENV["BUNDLE_GEMFILE"]
42 | return gemfile if gemfile && !gemfile.empty?
43 |
44 | File.expand_path("../../Gemfile", __FILE__)
45 | end
46 |
47 | def lockfile
48 | lockfile =
49 | case File.basename(gemfile)
50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile)
51 | else "#{gemfile}.lock"
52 | end
53 | File.expand_path(lockfile)
54 | end
55 |
56 | def lockfile_version
57 | return unless File.file?(lockfile)
58 | lockfile_contents = File.read(lockfile)
59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
60 | Regexp.last_match(1)
61 | end
62 |
63 | def bundler_version
64 | @bundler_version ||=
65 | env_var_version || cli_arg_version ||
66 | lockfile_version
67 | end
68 |
69 | def bundler_requirement
70 | return "#{Gem::Requirement.default}.a" unless bundler_version
71 |
72 | bundler_gem_version = Gem::Version.new(bundler_version)
73 |
74 | requirement = bundler_gem_version.approximate_recommendation
75 |
76 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0")
77 |
78 | requirement += ".a" if bundler_gem_version.prerelease?
79 |
80 | requirement
81 | end
82 |
83 | def load_bundler!
84 | ENV["BUNDLE_GEMFILE"] ||= gemfile
85 |
86 | activate_bundler
87 | end
88 |
89 | def activate_bundler
90 | gem_error = activation_error_handling do
91 | gem "bundler", bundler_requirement
92 | end
93 | return if gem_error.nil?
94 | require_error = activation_error_handling do
95 | require "bundler/version"
96 | end
97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
99 | exit 42
100 | end
101 |
102 | def activation_error_handling
103 | yield
104 | nil
105 | rescue StandardError, LoadError => e
106 | e
107 | end
108 | end
109 |
110 | m.load_bundler!
111 |
112 | if m.invoked_as_script?
113 | load Gem.bin_path("bundler", "bundle")
114 | end
115 |
--------------------------------------------------------------------------------
/ops/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | x-app: &app
4 | image: ghcr.io/floriandejonckheere/code-red-app:v0.7.0
5 | volumes:
6 | - storage:/app/storage
7 | environment:
8 | RAILS_ENV: production
9 | RAILS_LOG_TO_STDOUT: 1
10 | RAILS_SERVE_STATIC_FILES: 1
11 |
12 | SECRET_KEY_BASE: ${SECRET_KEY_BASE}
13 | logging:
14 | driver: "json-file"
15 | options:
16 | max-file: "5"
17 | max-size: "1g"
18 | compress: "true"
19 | restart: unless-stopped
20 |
21 | services:
22 | postgres:
23 | image: postgres:12
24 | volumes:
25 | - postgres:/var/lib/postgresql/data/
26 | ports:
27 | - "5432:5432"
28 | environment:
29 | POSTGRES_USER: postgres
30 | POSTGRES_PASSWORD: postgres
31 | restart: unless-stopped
32 |
33 | redis:
34 | image: redislabs/redismod
35 | command: --appendonly yes --loadmodule /usr/lib/redis/modules/redisgraph.so
36 | environment:
37 | TZ: Europe/Berlin
38 | volumes:
39 | - redis:/data/
40 | restart: unless-stopped
41 |
42 | traefik:
43 | image: traefik:v2.4
44 | command: >-
45 | --log.level=DEBUG
46 | --api.dashboard=true
47 | --providers.docker
48 | --providers.docker.watch=true
49 | --providers.docker.exposedbydefault=false
50 | --entrypoints.websecure.address=:443
51 | --entrypoints.web.address=:80
52 | --entrypoints.web.http.redirections.entryPoint.to=websecure
53 | --serverstransport.insecureskipverify=true
54 | --certificatesresolvers.letsencrypt.acme.dnschallenge=true
55 | --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=gandiv5
56 | --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53
57 | --certificatesresolvers.letsencrypt.acme.email=florian@floriandejonckheere.be
58 | --certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/acme.json
59 | environment:
60 | GANDIV5_API_KEY: ${GANDIV5_API_KEY}
61 | volumes:
62 | - "/var/run/docker.sock:/var/run/docker.sock:ro"
63 | - "acme:/etc/traefik/acme/"
64 | ports:
65 | - "80:80"
66 | - "443:443"
67 | restart: unless-stopped
68 |
69 | app:
70 | <<: *app
71 | labels:
72 | traefik.enable: "true"
73 | traefik.http.middlewares.app-tls.redirectscheme.scheme: "https"
74 |
75 | traefik.http.middlewares.nginx-redirect.redirectregex.regex: "^(https?://)([^/]*)/(.*)$$"
76 | traefik.http.middlewares.nginx-redirect.redirectregex.replacement: "$${1}codered.pm/$${3}"
77 |
78 | traefik.http.routers.app.rule: "Host(`codered.pm`, `www.codered.pm`)"
79 | traefik.http.routers.app.entrypoints: "web"
80 | traefik.http.routers.app.middlewares: "app-tls"
81 |
82 | traefik.http.routers.nginx-redirect.rule: "Host(`www.codered.pm`)"
83 | traefik.http.routers.nginx-redirect.entrypoints: "websecure"
84 | traefik.http.routers.nginx-redirect.tls: "true"
85 | traefik.http.routers.nginx-redirect.middlewares: "nginx-redirect"
86 | traefik.http.routers.nginx-redirect.tls.certresolver: "letsencrypt"
87 |
88 | traefik.http.routers.app-tls.rule: "Host(`codered.pm`)"
89 | traefik.http.routers.app-tls.entrypoints: "websecure"
90 | traefik.http.routers.app-tls.tls: "true"
91 | traefik.http.routers.app-tls.tls.certresolver: "letsencrypt"
92 |
93 | traefik.http.services.app.loadbalancer.server.port: "3000"
94 |
95 | migration:
96 | <<: *app
97 | command: bin/rails db:prepare
98 | restart: "no"
99 |
100 | volumes:
101 | postgres:
102 | redis:
103 | storage:
104 | acme:
105 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | push:
5 | branches:
6 | - '*'
7 | tags-ignore:
8 | - 'production'
9 |
10 | jobs:
11 | app:
12 | name: Continuous Integration (app)
13 | runs-on: ubuntu-20.04
14 |
15 | container:
16 | image: ruby:3.0-alpine
17 |
18 | services:
19 | redis:
20 | image: redislabs/redismod
21 |
22 | postgres:
23 | image: postgres:12
24 | env:
25 | POSTGRES_DB: postgres
26 | POSTGRES_USERNAME: postgres
27 | POSTGRES_PASSWORD: postgres
28 | options: >-
29 | --health-cmd pg_isready
30 | --health-interval 10s
31 | --health-timeout 5s
32 | --health-retries 5
33 |
34 | steps:
35 | - uses: actions/checkout@v2
36 |
37 | - name: Install dependencies
38 | run: |
39 | apk add postgresql
40 | apk add build-base curl-dev git postgresql-dev cmake
41 | apk add tar
42 |
43 | - name: Cache Gems
44 | uses: actions/cache@v2.1.3
45 | with:
46 | path: vendor/bundle
47 | key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
48 | restore-keys: |
49 | ${{ runner.os }}-gem-
50 |
51 | - name: Install Gems
52 | run: |
53 | gem install bundler
54 | bundle config path vendor/bundle
55 | bundle install --jobs 4 --retry 3
56 |
57 | - name: Test
58 | env:
59 | RAILS_ENV: test
60 | run: |
61 | bundle exec rails db:create db:schema:load --trace
62 | bundle exec rails database:seed:production --trace
63 | bundle exec rspec --profile 10 --format progress
64 |
65 | - name: Lint
66 | run: bundle exec rubocop --parallel --display-cop-names
67 |
68 | build:
69 | name: Build application
70 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
71 | runs-on: ubuntu-20.04
72 | needs:
73 | - app
74 |
75 | steps:
76 | - name: Set version
77 | run: |
78 | echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
79 |
80 | - name: Set up QEMU
81 | uses: docker/setup-qemu-action@v1
82 |
83 | - name: Set up Docker Buildx
84 | uses: docker/setup-buildx-action@v1
85 | with:
86 | driver-opts: image=moby/buildkit:master
87 |
88 | - name: Login to Github Packages
89 | uses: docker/login-action@v1
90 | with:
91 | registry: ghcr.io
92 | username: ${{ github.repository_owner }}
93 | password: ${{ secrets.DOCKER_TOKEN }}
94 |
95 | - name: Build and push app to GitHub Packages
96 | uses: docker/build-push-action@v2.0.1
97 | with:
98 | file: Dockerfile.prod
99 | push: true
100 | tags: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}-app:${{ env.VERSION }}
101 |
102 | release:
103 | name: Create release
104 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
105 | runs-on: ubuntu-20.04
106 | needs: build
107 |
108 | steps:
109 | - name: Set version
110 | run: |
111 | echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
112 |
113 | - name: Create Github Release
114 | uses: actions/create-release@v1
115 | env:
116 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117 | with:
118 | tag_name: ${{ github.ref }}
119 | release_name: ${{ github.event.repository.name }} ${{ env.VERSION }}
120 | prerelease: contains(github.ref, '-')
121 | body: |
122 | Application ${{ github.event.repository.name }} ${{ env.VERSION }} was released
123 |
--------------------------------------------------------------------------------
/db/seeds/development/03_tasks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | puts "== Creating tasks =="
4 |
5 | def create(graph, type, title, description, **attrs)
6 | Task
7 | .new(graph: graph, type: type.to_s, title: title, description: description, **attrs)
8 | .tap(&:save)
9 | end
10 |
11 | def relate(from, type, to)
12 | Edge
13 | .new(graph: from.graph, from: from, type: type.to_s, to: to)
14 | .tap(&:save)
15 | end
16 |
17 | Project.find_each do |project|
18 | next if project.tasks.any?
19 |
20 | # Project
21 | project = create(project.graph, :idea, "Graph project management", "Use graphs to visualize and represent tasks, features and resources.")
22 |
23 | # deploy = create(project.graph, :task, "Deploy app", "Deploy application using a Continuous Deployment mechanism.")
24 |
25 | # relate(deploy, :child_of, project)
26 |
27 | # Tasks
28 | manage_tasks = create(project.graph, :epic, "Task management", "Task management involves creating, modifying and deleting tasks")
29 | create_task = create(project.graph, :feature, "Create a task", "As a user, I want to create a task within a project. A task can have a title, description, deadline (date), status (todo, in progress, review, done), type (task, idea, bug, feature, goal, epic) and assignee (user).")
30 | view_task = create(project.graph, :feature, "View a task", "As a user, I want to view a task. A popup should display the title, description, deadline, status, type and assignee of the task.")
31 | modify_task = create(project.graph, :feature, "Modify a task", "As a user, I want to modify a task. I should be able to modify the title, description, deadline, status, type and assignee.")
32 | delete_task = create(project.graph, :feature, "Delete a task", "As a user, I want to delete a task. A confirmation modal should be displayed before the task is deleted.")
33 |
34 | analyze_data = create(project.graph, :task, "Analyze data model", "Analyze the data model for tasks. Construct an ERD.")
35 | implement_data = create(project.graph, :task, "Implement data model", "Implement the data model for tasks. Tasks should be stored in Redis, and make use of the Redis Graph module.")
36 |
37 | quotes = create(project.graph, :bug, "Quotes not saved", "Tasks containing single quotes in title or description are not saved to the database. The server returns a 500 error.")
38 | saved = create(project.graph, :bug, "Graph not updated on save", "After saving a task, the rendered graph does not update automatically.")
39 |
40 | relate(manage_tasks, :child_of, project)
41 |
42 | relate(create_task, :child_of, manage_tasks)
43 | relate(view_task, :child_of, manage_tasks)
44 | relate(modify_task, :child_of, manage_tasks)
45 | relate(delete_task, :child_of, manage_tasks)
46 |
47 | relate(analyze_data, :child_of, create_task)
48 | relate(implement_data, :child_of, create_task)
49 | relate(quotes, :related_to, modify_task)
50 | relate(saved, :related_to, modify_task)
51 |
52 | # Relationships
53 | manage_relationships = create(project.graph, :epic, "Relationship management", "Relationship management involves creating, modifying and deleting relationships between tasks")
54 | create_relationship = create(project.graph, :feature, "Create a relationship", "As a user, I want to create a relationship between two tasks. A relationship should have a type: `blocks`/`blocked_by`, `parent_of`/`child_of` or `related` (bidirectional).")
55 | view_relationship = create(project.graph, :feature, "View a tasks relationships", "As a user, I want to list all relationships, their type and their corresponding related tasks belonging to a task.")
56 | delete_relationship = create(project.graph, :feature, "Delete a relationship", "As a user, I want to delete a relationship. No confirmation modal should be displayed before the task is deleted.")
57 |
58 | relate(manage_relationships, :child_of, project)
59 |
60 | relate(manage_relationships, :blocked_by, manage_tasks)
61 |
62 | relate(create_relationship, :child_of, manage_relationships)
63 | relate(view_relationship, :child_of, manage_relationships)
64 | relate(delete_relationship, :child_of, manage_relationships)
65 |
66 | # Views
67 | views = create(project.graph, :epic, "Views", "Views involve displaying, filtering and organizing tasks and their relationships on the screen")
68 | view_graph = create(project.graph, :feature, "View graph", "As a user, I want to visualize the tasks in the project as a graph.")
69 | view_timeline = create(project.graph, :feature, "View timeline", "As a user, I want to visualize the tasks in the project in a timeline. Tasks blocked by other tasks should appear first in the timeline. Tasks with a deadline should appear on that date in the timeline.")
70 |
71 | analyze_graph = create(project.graph, :task, "Analyze graph libraries", "Analyze existing graph JavaScript libraries, taking into account the use cases.")
72 | implement_graph = create(project.graph, :task, "Implement graph library", "Implement the graph rendering algorithm using the JavaScript graph library.")
73 | overlap = create(project.graph, :bug, "Tasks overlap", "Disconnected tasks can overlap other tasks. Fix the bounding box algorithm to take into account disconnected graphs.")
74 |
75 | relate(views, :child_of, project)
76 |
77 | relate(view_graph, :child_of, views)
78 | relate(analyze_graph, :child_of, view_graph)
79 | relate(implement_graph, :child_of, view_graph)
80 | relate(overlap, :child_of, view_graph)
81 |
82 | relate(view_timeline, :child_of, views)
83 | end
84 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file was generated by the `rspec --init` command. Conventionally, all
4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5 | # The generated `.rspec` file contains `--require spec_helper` which will cause
6 | # this file to always be loaded, without a need to explicitly require it in any
7 | # files.
8 | #
9 | # Given that it is always loaded, you are encouraged to keep this file as
10 | # light-weight as possible. Requiring heavyweight dependencies from this file
11 | # will add to the boot time of your test suite on EVERY test run, even for an
12 | # individual file that may not need all of that loaded. Instead, consider making
13 | # a separate helper file that requires the additional dependencies and performs
14 | # the additional setup, and require it from the spec files that actually need
15 | # it.
16 | #
17 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
18 |
19 | RSpec.configure do |config|
20 | # rspec-expectations config goes here. You can use an alternate
21 | # assertion/expectation library such as wrong or the stdlib/minitest
22 | # assertions if you prefer.
23 | config.expect_with :rspec do |expectations|
24 | # This option will default to `true` in RSpec 4. It makes the `description`
25 | # and `failure_message` of custom matchers include text for helper methods
26 | # defined using `chain`, e.g.:
27 | # be_bigger_than(2).and_smaller_than(4).description
28 | # # => "be bigger than 2 and smaller than 4"
29 | # ...rather than:
30 | # # => "be bigger than 2"
31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
32 | end
33 |
34 | # rspec-mocks config goes here. You can use an alternate test double
35 | # library (such as bogus or mocha) by changing the `mock_with` option here.
36 | config.mock_with :rspec do |mocks|
37 | # Prevents you from mocking or stubbing a method that does not exist on
38 | # a real object. This is generally recommended, and will default to
39 | # `true` in RSpec 4.
40 | mocks.verify_partial_doubles = true
41 | end
42 |
43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
44 | # have no way to turn it off -- the option exists only for backwards
45 | # compatibility in RSpec 3). It causes shared context metadata to be
46 | # inherited by the metadata hash of host groups and examples, rather than
47 | # triggering implicit auto-inclusion in groups with matching metadata.
48 | config.shared_context_metadata_behavior = :apply_to_host_groups
49 |
50 | # The settings below are suggested to provide a good initial experience
51 | # with RSpec, but feel free to customize to your heart's content.
52 | # # This allows you to limit a spec run to individual examples or groups
53 | # # you care about by tagging them with `:focus` metadata. When nothing
54 | # # is tagged with `:focus`, all examples get run. RSpec also provides
55 | # # aliases for `it`, `describe`, and `context` that include `:focus`
56 | # # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
57 | # config.filter_run_when_matching :focus
58 | #
59 | # # Allows RSpec to persist some state between runs in order to support
60 | # # the `--only-failures` and `--next-failure` CLI options. We recommend
61 | # # you configure your source control system to ignore this file.
62 | config.example_status_persistence_file_path = "spec/examples.txt"
63 |
64 | # # Limits the available syntax to the non-monkey patched syntax that is
65 | # # recommended. For more details, see:
66 | # # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
67 | # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
68 | # # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
69 | # config.disable_monkey_patching!
70 | #
71 | # # This setting enables warnings. It's recommended, but in some cases may
72 | # # be too noisy due to issues in dependencies.
73 | # config.warnings = true
74 | #
75 | # # Many RSpec users commonly either run the entire suite or an individual
76 | # # file, and it's useful to allow more verbose output when running an
77 | # # individual spec file.
78 | # if config.files_to_run.one?
79 | # # Use the documentation formatter for detailed output,
80 | # # unless a formatter has already been configured
81 | # # (e.g. via a command-line flag).
82 | # config.default_formatter = "doc"
83 | # end
84 | #
85 | # # Print the 10 slowest examples and example groups at the
86 | # # end of the spec run, to help surface which specs are running
87 | # # particularly slow.
88 | # config.profile_examples = 10
89 | #
90 | # # Run specs in random order to surface order dependencies. If you find an
91 | # # order dependency and want to debug it, you can fix the order by providing
92 | # # the seed, which is printed after each run.
93 | # # --seed 1234
94 | config.order = :random
95 | #
96 | # # Seed global randomization in this process using the `--seed` CLI option.
97 | # # Setting this allows you to use `--seed` to deterministically reproduce
98 | # # test failures related to randomization by passing the same `--seed` value
99 | # # as the one that triggered the failure.
100 | Kernel.srand config.seed
101 | end
102 |
--------------------------------------------------------------------------------
/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/integer/time"
4 |
5 | Rails.application.configure do
6 | # Settings specified here will take precedence over those in config/application.rb.
7 |
8 | # Code is not reloaded between requests.
9 | config.cache_classes = true
10 |
11 | # Eager load code on boot. This eager loads most of Rails and
12 | # your application in memory, allowing both threaded web servers
13 | # and those relying on copy on write to perform better.
14 | # Rake tasks automatically ignore this option for performance.
15 | config.eager_load = true
16 |
17 | # Full error reports are disabled and caching is turned on.
18 | config.consider_all_requests_local = false
19 | config.action_controller.perform_caching = true
20 |
21 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
22 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
23 | # config.require_master_key = true
24 |
25 | # Disable serving static files from the `/public` folder by default since
26 | # Apache or NGINX already handles this.
27 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
28 |
29 | # Compress CSS using a preprocessor.
30 | # config.assets.css_compressor = :purger
31 |
32 | # Do not fallback to assets pipeline if a precompiled asset is missed.
33 | # config.assets.compile = false
34 |
35 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
36 | # config.asset_host = 'http://assets.example.com'
37 |
38 | # Specifies the header that your server uses for sending files.
39 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
41 |
42 | # Store uploaded files on the local file system (see config/storage.yml for options).
43 | config.active_storage.service = :local
44 |
45 | # Mount Action Cable outside main process or domain.
46 | # config.action_cable.mount_path = nil
47 | # config.action_cable.url = 'wss://example.com/cable'
48 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
49 |
50 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
51 | # config.force_ssl = true
52 |
53 | # Include generic and useful information about system operation, but avoid logging too much
54 | # information to avoid inadvertent exposure of personally identifiable information (PII).
55 | config.log_level = :info
56 |
57 | # Prepend all log lines with the following tags.
58 | config.log_tags = [:request_id]
59 |
60 | # Use a different cache store in production.
61 | # config.cache_store = :mem_cache_store
62 |
63 | # Use a real queuing backend for Active Job (and separate queues per environment).
64 | # config.active_job.queue_adapter = :resque
65 | # config.active_job.queue_name_prefix = "code_red_production"
66 |
67 | config.action_mailer.perform_caching = false
68 |
69 | # Ignore bad email addresses and do not raise email delivery errors.
70 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
71 | # config.action_mailer.raise_delivery_errors = false
72 |
73 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
74 | # the I18n.default_locale when a translation cannot be found).
75 | config.i18n.fallbacks = true
76 |
77 | # Send deprecation notices to registered listeners.
78 | config.active_support.deprecation = :notify
79 |
80 | # Log disallowed deprecations.
81 | config.active_support.disallowed_deprecation = :log
82 |
83 | # Tell Active Support which deprecation messages to disallow.
84 | config.active_support.disallowed_deprecation_warnings = []
85 |
86 | # Use default logging formatter so that PID and timestamp are not suppressed.
87 | config.log_formatter = ::Logger::Formatter.new
88 |
89 | # Use a different logger for distributed setups.
90 | # require "syslog/logger"
91 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
92 |
93 | if ENV["RAILS_LOG_TO_STDOUT"].present?
94 | logger = ActiveSupport::Logger.new($stdout)
95 | logger.formatter = config.log_formatter
96 | config.logger = ActiveSupport::TaggedLogging.new(logger)
97 | end
98 |
99 | # Do not dump schema after migrations.
100 | config.active_record.dump_schema_after_migration = false
101 |
102 | # Inserts middleware to perform automatic connection switching.
103 | # The `database_selector` hash is used to pass options to the DatabaseSelector
104 | # middleware. The `delay` is used to determine how long to wait after a write
105 | # to send a subsequent read to the primary.
106 | #
107 | # The `database_resolver` class is used by the middleware to determine which
108 | # database is appropriate to use based on the time delay.
109 | #
110 | # The `database_resolver_context` class is used by the middleware to set
111 | # timestamps for the last write to the primary. The resolver uses the context
112 | # class timestamps to determine how long to wait before reading from the
113 | # replica.
114 | #
115 | # By default Rails will store a last write timestamp in the session. The
116 | # DatabaseSelector middleware is designed as such you can define your own
117 | # strategy for connection switching and pass that into the middleware through
118 | # these configuration options.
119 | # config.active_record.database_selector = { delay: 2.seconds }
120 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
121 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
122 |
123 | config.hosts << "codered.pm"
124 | end
125 |
--------------------------------------------------------------------------------
/app/javascript/controllers/graph_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from 'stimulus'
2 |
3 | import settings from "../graph/settings"
4 |
5 | export default class extends Controller {
6 | static targets = ['container']
7 |
8 | static values = {
9 | projectId: String,
10 | }
11 |
12 | connect() {
13 | super.connect()
14 |
15 | this.svg = d3.select(this.containerTarget)
16 |
17 | // Arrow markers
18 | this.svg
19 | .append('svg:defs')
20 | .append('svg:marker')
21 | .attr('id', 'end-arrow')
22 | .attr('viewBox', '0 -5 10 10')
23 | .attr('refX', 5)
24 | .attr('markerWidth', 5)
25 | .attr('markerHeight', 5)
26 | .attr('orient', 'auto')
27 | .append('svg:path')
28 | .attr('d', 'M0,-5L10,0L0,5L2,0')
29 | .attr('stroke-width', '0px')
30 | .attr('fill', '#999')
31 |
32 | this.cola = window.cola.d3adaptor(d3)
33 | .linkDistance(settings.edge.length)
34 | .avoidOverlaps(true)
35 | .handleDisconnected(true)
36 | .convergenceThreshold(1e-3)
37 | .size([
38 | this.svg.node().getBoundingClientRect().width,
39 | this.svg.node().getBoundingClientRect().height,
40 | ])
41 |
42 | this.zoom = d3
43 | .zoom()
44 | .scaleExtent([settings.zoom.min, settings.zoom.max])
45 | .on('zoom', () => {
46 | this.container
47 | .attr('transform', d3.event.transform)
48 | })
49 |
50 | this.svg
51 | .append('rect')
52 | .attr('class', 'background')
53 | .attr('width', '100%')
54 | .attr('height', '100%')
55 | .call(this.zoom)
56 |
57 | this.container = this.svg
58 | .append('g')
59 | .attr('transform', 'translate(0, 0)')
60 |
61 | this.render()
62 | }
63 |
64 | reset() {
65 | setTimeout(() => {
66 | this.container
67 | .selectAll('*')
68 | .remove()
69 |
70 | this.render()
71 | }, 100)
72 | }
73 |
74 | render() {
75 | fetch(`/projects/${this.projectIdValue}/tasks.json`)
76 | .then(response => response.json())
77 | .then(graph => {
78 | // Inject height/width for bounding boxes
79 | graph.nodes = graph.nodes
80 | .map(node => { return { ...node, width: (settings.node.width + settings.node.padding), height: (settings.node.height + settings.node.padding) } })
81 |
82 | this.cola
83 | .linkDistance(settings.edge.length)
84 | .nodes(graph.nodes)
85 | .links(graph.edges)
86 | // .constraints(graph.constraints)
87 | .groups(graph.groups)
88 | .start(settings.iterations.layout, settings.iterations.structural, settings.iterations.all)
89 |
90 | setTimeout(() => { this.cola.stop() }, settings.iterations.timeout)
91 |
92 | const edge = this.container
93 | .selectAll('.link')
94 | .data(graph.edges)
95 | .enter()
96 | .append('line')
97 | .attr('class', 'link')
98 |
99 | const edgeLabel = this.container
100 | .selectAll('.link-label')
101 | .data(graph.edges)
102 | .enter()
103 | .append('text')
104 | .attr('class', 'link-label')
105 | .text(d => d.label)
106 | .call(this.cola.drag)
107 |
108 | const anchor = this.container
109 | .selectAll('.anchor')
110 | .data(graph.nodes)
111 | .enter()
112 | .append('a')
113 | .attr('class', 'anchor')
114 | .attr('data-action', 'click->task-modal#open')
115 | .attr('data-turbo-frame', 'task')
116 | .attr('href', d => `/projects/${this.projectIdValue}/tasks/${d.id}/edit`)
117 | .attr('width', settings.node.width)
118 | .attr('height', settings.node.height)
119 | .call(this.cola.drag)
120 |
121 | const node = anchor
122 | .append('rect')
123 | .attr('class', 'node')
124 | .attr('width', settings.node.width)
125 | .attr('height', settings.node.height)
126 | .attr('rx', settings.node.radius)
127 | .attr('ry', settings.node.radius)
128 | .call(this.cola.drag)
129 |
130 | node
131 | .append('title')
132 | .text(d => d.label)
133 |
134 | const label = anchor
135 | .append('foreignObject')
136 | .attr('class', d => `label text-${d.status}ZZ`)
137 | .attr('width', settings.node.width - (2 * settings.node.padding))
138 | .attr('height', 30)
139 | .text(d => d.label)
140 | .call(this.cola.drag)
141 |
142 | const icon = anchor
143 | .append('svg')
144 | .attr('width', 16)
145 | .attr('height', 16)
146 | .attr('viewBox', `0 0 24 24`)
147 | .attr('preserveAspectRatio', 'xMinYMin')
148 | .attr('class', d => `icon text-${d.color}`)
149 | .html(d => d.icon)
150 | .call(this.cola.drag)
151 |
152 | const type = anchor
153 | .append('text')
154 | .attr('class', d => `type text-${d.color}`)
155 | .text(d => d.type)
156 | .call(this.cola.drag)
157 |
158 | this.cola.on('tick', () => {
159 | node
160 | .attr('x', d => (d.x - settings.node.width / 2))
161 | .attr('y', d => (d.y - settings.node.height / 2))
162 | .each(d => d.innerBounds = d.bounds.inflate(-5))
163 |
164 | edge
165 | .each(d => d.route = window.cola.makeEdgeBetween(d.source.innerBounds, d.target.innerBounds, 5))
166 |
167 | edge
168 | .attr('x1', d => d.route.sourceIntersection.x)
169 | .attr('y1', d => d.route.sourceIntersection.y)
170 | .attr('x2', d => d.route.arrowStart.x)
171 | .attr('y2', d => d.route.arrowStart.y)
172 |
173 | edgeLabel
174 | .attr('transform', d => {
175 | // Calculate angle of edge
176 | const p1 = d.route.sourceIntersection
177 | const p2 = d.route.targetIntersection
178 |
179 | let rad = Math.atan2(p2.y - p1.y, p2.x - p1.x)
180 | let deg = rad * 180 / Math.PI
181 |
182 | // Calculate middle of edge
183 | let x = (p1.x + p2.x) / 2
184 | let y = (p1.y + p2.y) / 2
185 |
186 | // Flip labels if upside down
187 | if ((deg > 90 && deg < 270) || (deg < -90 && deg > -270)) {
188 | rad -= Math.PI
189 | deg -= 180
190 | }
191 |
192 | // Float labels above edge
193 | x += settings.edge.margin * Math.sin(rad)
194 | y -= settings.edge.margin * Math.cos(rad)
195 |
196 | return `translate(${x}, ${y}) rotate(${deg})`
197 | })
198 |
199 | label
200 | .attr('x', d => (d.x - settings.node.width / 2) + settings.node.padding)
201 | .attr('y', d => (d.y - settings.node.height / 2) + settings.node.padding)
202 |
203 | icon
204 | .attr('x', d => (d.x - settings.node.width / 2) + settings.node.padding)
205 | .attr('y', d => (d.y + settings.node.height / 2) - 16 - settings.node.padding)
206 |
207 | type
208 | .attr('x', d => (d.x - settings.node.width / 2) + 20 + settings.node.padding)
209 | .attr('y', d => (d.y + settings.node.height / 2) - 14)
210 | })
211 | })
212 | }
213 |
214 | zoomIn(e) {
215 | e.preventDefault()
216 |
217 | this.svg
218 | .transition()
219 | .duration(500)
220 | .call(this.zoom.scaleBy, settings.zoom.step)
221 | }
222 |
223 | zoomOut(e) {
224 | e.preventDefault()
225 |
226 | this.svg
227 | .transition()
228 | .duration(500)
229 | .call(this.zoom.scaleBy, -1 * settings.zoom.step)
230 | }
231 |
232 | zoomReset(e) {
233 | e.preventDefault()
234 |
235 | this.svg
236 | .transition()
237 | .duration(750)
238 | .call(this.zoom.transform, d3.zoomIdentity)
239 | }
240 | }
241 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.routes.draw do
4 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
5 |
6 | root to: redirect("/projects/055616f0-a130-42b1-a3fd-81b7c8a3ef1b/tasks")
7 |
8 | get "/years/:year/months/:month", to: "calendar#show", as: "calendar"
9 |
10 | resources :projects, only: :none do
11 | resources :tasks, except: :show
12 |
13 | resource :relationships, only: [:create, :destroy]
14 | end
15 | end
16 |
17 | # == Route Map
18 | #
19 | # Prefix Verb URI Pattern Controller#Action
20 | # root GET / redirect(301, /projects/055616f0-a130-42b1-a3fd-81b7c8a3ef1b/tasks)
21 | # calendar GET /years/:year/months/:month(.:format) calendar#show
22 | # project_tasks GET /projects/:project_id/tasks(.:format) tasks#index
23 | # POST /projects/:project_id/tasks(.:format) tasks#create
24 | # new_project_task GET /projects/:project_id/tasks/new(.:format) tasks#new
25 | # edit_project_task GET /projects/:project_id/tasks/:id/edit(.:format) tasks#edit
26 | # project_task PATCH /projects/:project_id/tasks/:id(.:format) tasks#update
27 | # PUT /projects/:project_id/tasks/:id(.:format) tasks#update
28 | # DELETE /projects/:project_id/tasks/:id(.:format) tasks#destroy
29 | # project_relationships DELETE /projects/:project_id/relationships(.:format) relationships#destroy
30 | # POST /projects/:project_id/relationships(.:format) relationships#create
31 | # turbo_recede_historical_location GET /recede_historical_location(.:format) turbo/native/navigation#recede
32 | # turbo_resume_historical_location GET /resume_historical_location(.:format) turbo/native/navigation#resume
33 | # turbo_refresh_historical_location GET /refresh_historical_location(.:format) turbo/native/navigation#refresh
34 | # rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create
35 | # rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create
36 | # rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create
37 | # rails_mandrill_inbound_health_check GET /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#health_check
38 | # rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create
39 | # rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create
40 | # rails_conductor_inbound_emails GET /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#index
41 | # POST /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#create
42 | # new_rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/new(.:format) rails/conductor/action_mailbox/inbound_emails#new
43 | # edit_rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/:id/edit(.:format) rails/conductor/action_mailbox/inbound_emails#edit
44 | # rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#show
45 | # PATCH /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#update
46 | # PUT /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#update
47 | # DELETE /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#destroy
48 | # new_rails_conductor_inbound_email_source GET /rails/conductor/action_mailbox/inbound_emails/sources/new(.:format) rails/conductor/action_mailbox/inbound_emails/sources#new
49 | # rails_conductor_inbound_email_sources POST /rails/conductor/action_mailbox/inbound_emails/sources(.:format) rails/conductor/action_mailbox/inbound_emails/sources#create
50 | # rails_conductor_inbound_email_reroute POST /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format) rails/conductor/action_mailbox/reroutes#create
51 | # rails_service_blob GET /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) active_storage/blobs/redirect#show
52 | # rails_service_blob_proxy GET /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format) active_storage/blobs/proxy#show
53 | # GET /rails/active_storage/blobs/:signed_id/*filename(.:format) active_storage/blobs/redirect#show
54 | # rails_blob_representation GET /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show
55 | # rails_blob_representation_proxy GET /rails/active_storage/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/proxy#show
56 | # GET /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show
57 | # rails_disk_service GET /rails/active_storage/disk/:encoded_key/*filename(.:format) active_storage/disk#show
58 | # update_rails_disk_service PUT /rails/active_storage/disk/:encoded_token(.:format) active_storage/disk#update
59 | # rails_direct_uploads POST /rails/active_storage/direct_uploads(.:format) active_storage/direct_uploads#create
60 |
--------------------------------------------------------------------------------
/spec/graph/dsl_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DSL do
4 | subject(:dsl) { build(:dsl, graph: graph) }
5 |
6 | let(:graph) { build(:graph) }
7 |
8 | let!(:node) { create(:node, graph: graph) }
9 | let!(:task0) { create(:task, graph: graph) }
10 | let!(:task1) { create(:task, graph: graph) }
11 |
12 | before { create(:edge, graph: graph, from: task0, type: "related_to", to: task1) }
13 |
14 | describe "#return" do
15 | it "returns all nodes" do
16 | query = dsl
17 | .match(:n)
18 | .return(:n)
19 |
20 | expect(query.to_cypher).to eq "MATCH (n) RETURN n"
21 | expect(query.execute)
22 | .to match_array [
23 | { n: including(id: node.id) },
24 | { n: including(id: task0.id) },
25 | { n: including(id: task1.id) },
26 | ]
27 | end
28 |
29 | it "returns nodes filtered on label" do
30 | query = dsl
31 | .match(:n, "Task")
32 | .return(:n)
33 |
34 | expect(query.to_cypher).to eq "MATCH (n:Task) RETURN n"
35 | expect(query)
36 | .to match_array [
37 | { n: including(id: task0.id) },
38 | { n: including(id: task1.id) },
39 | ]
40 | end
41 |
42 | it "returns a node" do
43 | query = dsl
44 | .match(:n, "Task", id: task0.id)
45 | .return(:n)
46 |
47 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}) RETURN n"
48 | expect(query)
49 | .to match_array [
50 | n: including(id: task0.id, title: task0.title),
51 | ]
52 | end
53 |
54 | it "returns multiple nodes" do
55 | query = dsl
56 | .match(:n, "Task", id: task0.id)
57 | .match(:m, "Task", id: task1.id)
58 | .return(:n, :m)
59 |
60 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}), (m:Task {id: '#{task1.id}'}) RETURN n, m"
61 | expect(query)
62 | .to match_array [
63 | {
64 | n: including(id: task0.id),
65 | m: including(id: task1.id),
66 | },
67 | ]
68 | end
69 |
70 | it "returns a node's relationships" do
71 | query = dsl
72 | .match(:n, "Task", id: task0.id)
73 | .to(:r, "related_to")
74 | .match(:m)
75 | .return(:n, :m, t: "type(r)")
76 |
77 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}) -[r:related_to]-> (m) RETURN n, m, type(r) AS t"
78 | expect(query)
79 | .to match_array [
80 | {
81 | n: including(id: task0.id),
82 | t: "related_to",
83 | m: including(id: task1.id),
84 | },
85 | ]
86 | end
87 | end
88 |
89 | describe "#delete" do
90 | it "deletes all nodes" do
91 | query = dsl
92 | .match(:n)
93 | .delete(:n)
94 |
95 | expect(query.to_cypher).to eq "MATCH (n) DELETE n"
96 | expect(query).to be_empty
97 |
98 | expect(graph.dsl.match(:n).return(:n)).to be_empty
99 | end
100 |
101 | it "deletes nodes filtered on label" do
102 | query = dsl
103 | .match(:n, "Task")
104 | .delete(:n)
105 |
106 | expect(query.to_cypher).to eq "MATCH (n:Task) DELETE n"
107 | expect(query).to be_empty
108 |
109 | expect(graph.dsl.match(:n).return(:n))
110 | .to match_array [
111 | n: including(id: node.id),
112 | ]
113 | end
114 |
115 | it "deletes a node" do
116 | query = dsl
117 | .match(:n, "Task", id: task0.id)
118 | .delete(:n)
119 |
120 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}) DELETE n"
121 | expect(query).to be_empty
122 |
123 | expect(graph.dsl.match(:n).return(:n))
124 | .to match_array [
125 | { n: including(id: node.id) },
126 | { n: including(id: task1.id) },
127 | ]
128 | end
129 |
130 | it "deletes a node's relationships" do
131 | query = dsl
132 | .match(:n, "Task", id: task0.id)
133 | .to(:r, "related_to")
134 | .match(:m)
135 | .delete(:r)
136 |
137 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}) -[r:related_to]-> (m) DELETE r"
138 | expect(query).to be_empty
139 |
140 | expect(graph.dsl.match(:n, "Task", id: task0.id).to(:r, "related_to").match(:m).return(:n, :m, t: "type(r)")).to be_empty
141 | end
142 | end
143 |
144 | describe "#set" do
145 | it "sets properties on all nodes" do
146 | query = dsl
147 | .match(:n)
148 | .set(title: "New title")
149 |
150 | expect(query.to_cypher).to eq "MATCH (n) SET n.title = 'New title'"
151 | expect(query).to be_empty
152 |
153 | expect(graph.dsl.match(:n).return(:n))
154 | .to match_array [
155 | { n: including(id: node.id, title: "New title") },
156 | { n: including(id: task0.id, title: "New title") },
157 | { n: including(id: task1.id, title: "New title") },
158 | ]
159 | end
160 |
161 | it "sets properties on nodes filtered on label" do
162 | query = dsl
163 | .match(:n, "Task")
164 | .set(title: "New title")
165 |
166 | expect(query.to_cypher).to eq "MATCH (n:Task) SET n.title = 'New title'"
167 | expect(query).to be_empty
168 |
169 | expect(graph.dsl.match(:n).return(:n))
170 | .to match_array [
171 | { n: including(id: node.id) },
172 | { n: including(id: task0.id, title: "New title") },
173 | { n: including(id: task1.id, title: "New title") },
174 | ]
175 | end
176 |
177 | it "sets properties on a node" do
178 | query = dsl
179 | .match(:n, "Task", id: task0.id)
180 | .set(title: "New title")
181 |
182 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}) SET n.title = 'New title'"
183 | expect(query).to be_empty
184 |
185 | expect(graph.dsl.match(:n).return(:n))
186 | .to match_array [
187 | { n: including(id: node.id) },
188 | { n: including(id: task0.id, title: "New title") },
189 | { n: including(id: task1.id, title: task1.title) },
190 | ]
191 | end
192 | end
193 |
194 | describe "#merge" do
195 | it "creates a node if it does not exist" do
196 | query = dsl
197 | .merge(:n, "Task", id: "new_id")
198 | .set(title: "New title")
199 |
200 | expect(query.to_cypher).to eq "MERGE (n:Task {id: 'new_id'}) SET n.title = 'New title'"
201 | expect(query).to be_empty
202 |
203 | expect(graph.dsl.match(:n).return(:n))
204 | .to match_array [
205 | { n: including(id: node.id) },
206 | { n: including(id: task0.id, title: task0.title) },
207 | { n: including(id: task1.id, title: task1.title) },
208 | { n: including(id: "new_id", title: "New title") },
209 | ]
210 | end
211 |
212 | it "merges a node if it exists" do
213 | query = dsl
214 | .merge(:n, "Task", id: task0.id)
215 | .set(title: "New title")
216 |
217 | expect(query.to_cypher).to eq "MERGE (n:Task {id: '#{task0.id}'}) SET n.title = 'New title'"
218 | expect(query).to be_empty
219 |
220 | expect(graph.dsl.match(:n).return(:n))
221 | .to match_array [
222 | { n: including(id: node.id) },
223 | { n: including(id: task0.id, title: "New title") },
224 | { n: including(id: task1.id, title: task1.title) },
225 | ]
226 | end
227 |
228 | it "merges a relationship if it does not exist" do
229 | query = dsl
230 | .match(:n, "Task", id: task0.id)
231 | .match(:m, "Node", id: node.id)
232 | .merge(:n)
233 | .to(:r, "related_to")
234 | .merge(:m)
235 |
236 | expect(query.to_cypher).to eq "MATCH (n:Task {id: '#{task0.id}'}), (m:Node {id: '#{node.id}'}) MERGE (n) -[r:related_to]-> (m)"
237 | expect(query).to be_empty
238 |
239 | expect(graph.dsl.match(:n, "Task", id: task0.id).to(:r, "related_to").match(:m, "Node").return(:n, :m, t: "type(r)"))
240 | .to match_array [
241 | {
242 | n: including(id: task0.id),
243 | t: "related_to",
244 | m: including(id: node.id),
245 | },
246 | ]
247 | end
248 | end
249 | end
250 |
--------------------------------------------------------------------------------
/terraform/main.tf:
--------------------------------------------------------------------------------
1 | ##
2 | # Backend
3 | #
4 | terraform {
5 | backend "local" {
6 | path = "terraform.tfstate"
7 | }
8 | }
9 |
10 | ##
11 | # Providers
12 | #
13 | provider "hcloud" {
14 | token = var.hcloud_token
15 | }
16 |
17 | provider "gandi" {
18 | key = var.gandi_key
19 | }
20 |
21 |
22 | ##
23 | # Locals
24 | #
25 | locals {
26 | fqdn = "codered.cloud.dejonckhee.re"
27 | cloud_init = templatefile("default.tmpl.yml", {
28 | ip_address = hcloud_floating_ip.default.ip_address,
29 | ssh_public_key = file("~/.ssh/code-red.pub"),
30 | sshd_public_key = file("~/.ssh/code-red-sshd.pub"),
31 | sshd_private_key = file("~/.ssh/code-red-sshd"),
32 | fqdn = local.fqdn,
33 | hostname = "default",
34 | passwd = var.passwd,
35 | default_volume = hcloud_volume.default.linux_device,
36 | })
37 | }
38 |
39 | ##
40 | # Authentication
41 | #
42 | resource "hcloud_ssh_key" "default" {
43 | name = "default"
44 | public_key = file("~/.ssh/code-red.pub")
45 | }
46 |
47 | ##
48 | # Instances
49 | #
50 | resource "hcloud_server" "default" {
51 | name = "default"
52 | location = "hel1"
53 | image = "debian-10"
54 | ssh_keys = [hcloud_ssh_key.default.id]
55 | user_data = local.cloud_init
56 | backups = false
57 | firewall_ids = [hcloud_firewall.default.id]
58 |
59 | # Type vCPU RAM Price/h Price/mo
60 | # cx11 1 2G 0.005/h 3.01/mo
61 | # cpx11 2 2G 0.007/h 4.22/mo
62 | # cx21 2 4G 0.010/h 5.93/mo
63 | # cpx21 3 4G 0.013/h 8.35/mo
64 | # cx31 2 8G 0.017/h 10.77/mo
65 | # cpx31 4 8G 0.024/h 15.00/mo
66 | # cx41 4 16G 0.031/h 19.24/mo
67 | # cpx41 8 16G 0.046/h 27.71/mo
68 | # cx51 8 32G 0.061/h 36.18/mo
69 | # cpx51 16 32G 0.097/h 60.38/mo
70 | server_type = "cx11"
71 | }
72 |
73 | ##
74 | # Volumes
75 | #
76 | resource "hcloud_volume" "default" {
77 | name = "default"
78 | location = "hel1"
79 | size = 10
80 | format = "ext4"
81 |
82 | lifecycle {
83 | prevent_destroy = true
84 | }
85 | }
86 |
87 | resource "hcloud_volume_attachment" "default" {
88 | volume_id = hcloud_volume.default.id
89 | server_id = hcloud_server.default.id
90 | }
91 |
92 | ##
93 | # Network
94 | #
95 | resource "hcloud_rdns" "default4" {
96 | server_id = hcloud_server.default.id
97 | ip_address = hcloud_server.default.ipv4_address
98 | dns_ptr = local.fqdn
99 | }
100 |
101 | resource "hcloud_rdns" "default6" {
102 | server_id = hcloud_server.default.id
103 | ip_address = hcloud_server.default.ipv6_address
104 | dns_ptr = local.fqdn
105 | }
106 |
107 | resource "hcloud_network" "default" {
108 | name = "default"
109 | ip_range = "10.0.0.0/8"
110 | }
111 |
112 | resource "hcloud_network_subnet" "default" {
113 | network_id = hcloud_network.default.id
114 | type = "server"
115 | network_zone = "eu-central"
116 | ip_range = "10.0.1.0/24"
117 | }
118 |
119 | resource "hcloud_server_network" "default" {
120 | server_id = hcloud_server.default.id
121 | network_id = hcloud_network.default.id
122 | }
123 |
124 | resource "hcloud_floating_ip" "default" {
125 | name = "default"
126 | home_location = "hel1"
127 | type = "ipv4"
128 |
129 | lifecycle {
130 | prevent_destroy = true
131 | }
132 | }
133 |
134 | resource "hcloud_floating_ip_assignment" "default" {
135 | floating_ip_id = hcloud_floating_ip.default.id
136 | server_id = hcloud_server.default.id
137 | }
138 |
139 | resource "hcloud_rdns" "floating_default" {
140 | floating_ip_id = hcloud_floating_ip.default.id
141 | ip_address = hcloud_floating_ip.default.ip_address
142 | dns_ptr = local.fqdn
143 | }
144 |
145 | ##
146 | # Firewall
147 | #
148 | resource "hcloud_firewall" "default" {
149 | name = "default"
150 |
151 | # Ping
152 | rule {
153 | direction = "in"
154 | protocol = "icmp"
155 | source_ips = ["0.0.0.0/0", "::/0"]
156 | }
157 |
158 | # SSH
159 | rule {
160 | direction = "in"
161 | protocol = "tcp"
162 | port = "22"
163 | source_ips = ["0.0.0.0/0", "::/0"]
164 | }
165 |
166 | # HTTP/HTTPS
167 | rule {
168 | direction = "in"
169 | protocol = "tcp"
170 | port = "80"
171 | source_ips = ["0.0.0.0/0", "::/0"]
172 | }
173 |
174 | rule {
175 | direction = "in"
176 | protocol = "tcp"
177 | port = "443"
178 | source_ips = ["0.0.0.0/0", "::/0"]
179 | }
180 |
181 | rule {
182 | direction = "out"
183 | protocol = "tcp"
184 | port = "80"
185 | destination_ips = ["0.0.0.0/0", "::/0"]
186 | }
187 |
188 | rule {
189 | direction = "out"
190 | protocol = "tcp"
191 | port = "443"
192 | destination_ips = ["0.0.0.0/0", "::/0"]
193 | }
194 |
195 | # DNS
196 | rule {
197 | direction = "out"
198 | protocol = "tcp"
199 | port = "53"
200 | destination_ips = ["0.0.0.0/0", "::/0"]
201 | }
202 |
203 | rule {
204 | direction = "out"
205 | protocol = "udp"
206 | port = "53"
207 | destination_ips = ["0.0.0.0/0", "::/0"]
208 | }
209 | }
210 |
211 | ##
212 | # DNS
213 | #
214 | ##
215 | # Domain
216 | #
217 | data "gandi_livedns_domain_ns" "default" {
218 | name = "codered.pm"
219 | }
220 |
221 | resource "gandi_domain" "default" {
222 | name = "codered.pm"
223 | autorenew = false
224 | nameservers = data.gandi_livedns_domain_ns.default.nameservers
225 |
226 | admin {
227 | type = "person"
228 |
229 | country = lookup(var.domain_contact, "country")
230 | email = lookup(var.domain_contact, "email")
231 | family_name = lookup(var.domain_contact, "family_name")
232 | given_name = lookup(var.domain_contact, "given_name")
233 | phone = lookup(var.domain_contact, "phone")
234 | street_addr = lookup(var.domain_contact, "street_addr")
235 | city = lookup(var.domain_contact, "city")
236 | zip = lookup(var.domain_contact, "zip")
237 | extra_parameters = var.domain_contact_extra
238 |
239 | data_obfuscated = true
240 | mail_obfuscated = true
241 | }
242 |
243 | billing {
244 | type = "person"
245 |
246 | country = lookup(var.domain_contact, "country")
247 | email = lookup(var.domain_contact, "email")
248 | family_name = lookup(var.domain_contact, "family_name")
249 | given_name = lookup(var.domain_contact, "given_name")
250 | phone = lookup(var.domain_contact, "phone")
251 | street_addr = lookup(var.domain_contact, "street_addr")
252 | city = lookup(var.domain_contact, "city")
253 | zip = lookup(var.domain_contact, "zip")
254 | extra_parameters = var.domain_contact_extra
255 |
256 | data_obfuscated = true
257 | mail_obfuscated = true
258 | }
259 |
260 | owner {
261 | type = "person"
262 |
263 | country = lookup(var.domain_contact, "country")
264 | email = lookup(var.domain_contact, "email")
265 | family_name = lookup(var.domain_contact, "family_name")
266 | given_name = lookup(var.domain_contact, "given_name")
267 | phone = lookup(var.domain_contact, "phone")
268 | street_addr = lookup(var.domain_contact, "street_addr")
269 | city = lookup(var.domain_contact, "city")
270 | zip = lookup(var.domain_contact, "zip")
271 | extra_parameters = var.domain_contact_extra
272 |
273 | data_obfuscated = true
274 | mail_obfuscated = true
275 | }
276 |
277 | tech {
278 | type = "person"
279 |
280 | country = lookup(var.domain_contact, "country")
281 | email = lookup(var.domain_contact, "email")
282 | family_name = lookup(var.domain_contact, "family_name")
283 | given_name = lookup(var.domain_contact, "given_name")
284 | phone = lookup(var.domain_contact, "phone")
285 | street_addr = lookup(var.domain_contact, "street_addr")
286 | city = lookup(var.domain_contact, "city")
287 | zip = lookup(var.domain_contact, "zip")
288 | extra_parameters = var.domain_contact_extra
289 |
290 | data_obfuscated = true
291 | mail_obfuscated = true
292 | }
293 |
294 | lifecycle {
295 | ignore_changes = [
296 | admin,
297 | billing,
298 | owner,
299 | tech,
300 | ]
301 | }
302 | }
303 |
304 | resource "gandi_livedns_record" "a" {
305 | zone = "codered.pm"
306 | name = "@"
307 | type = "A"
308 | values = [hcloud_floating_ip.default.ip_address]
309 | ttl = 1800
310 | }
311 |
312 | resource "gandi_livedns_record" "cname" {
313 | zone = "codered.pm"
314 | name = "www"
315 | type = "CNAME"
316 | values = ["codered.pm."]
317 | ttl = 1800
318 | }
319 |
320 | resource "gandi_livedns_record" "aaaa" {
321 | zone = "codered.pm"
322 | name = "@"
323 | type = "AAAA"
324 | values = [hcloud_server.default.ipv6_address]
325 | ttl = 1800
326 | }
327 |
328 | resource "gandi_livedns_record" "caa" {
329 | zone = "codered.pm"
330 | name = "@"
331 | type = "CAA"
332 | values = ["0 issue \"letsencrypt.org\""]
333 | ttl = 1800
334 | }
335 |
--------------------------------------------------------------------------------