From 29ebe095553fc55cf23d436b00a4e1d19ce5b90c Mon Sep 17 00:00:00 2001 From: Imanol Cea Date: Tue, 7 Jan 2020 10:50:52 +0100 Subject: [PATCH] Initial commit --- .dockerignore | 3 + .formatter.exs | 5 + .gitignore | 41 + Dockerfile | 31 + README.md | 17 + assets/.babelrc | 5 + assets/css/app.css | 13 + assets/css/live_view.css | 67 + assets/js/adminlte.js | 1835 +++ assets/js/app.js | 66 + assets/package-lock.json | 10147 ++++++++++++++++ assets/package.json | 32 + assets/static/favicon.ico | Bin 0 -> 1258 bytes assets/static/images/phoenix.png | Bin 0 -> 13900 bytes assets/static/robots.txt | 5 + assets/webpack.config.js | 43 + config/config.exs | 41 + config/dev.exs | 88 + config/dummy-credentials.json | 12 + config/prod.exs | 64 + config/releases.exs | 36 + config/test.exs | 29 + docker/docker-compose.yml | 27 + lib/postoffice.ex | 95 + lib/postoffice/adapters/http.ex | 26 + lib/postoffice/adapters/impl.ex | 7 + lib/postoffice/adapters/pubsub.ex | 44 + lib/postoffice/application.ex | 35 + lib/postoffice/dispatch.ex | 17 + lib/postoffice/handlers/http.ex | 44 + lib/postoffice/handlers/pubsub.ex | 45 + lib/postoffice/messages_consumer.ex | 24 + .../messages_consumer_supervisor.ex | 27 + lib/postoffice/messages_producer.ex | 76 + .../messages_producer_supervisor.ex | 18 + lib/postoffice/messaging.ex | 268 + lib/postoffice/messaging/message.ex | 26 + lib/postoffice/messaging/publisher.ex | 27 + .../messaging/publisher_failures.ex | 18 + lib/postoffice/messaging/publisher_success.ex | 17 + lib/postoffice/messaging/topic.ex | 20 + lib/postoffice/publisher_producer.ex | 48 + lib/postoffice/release.ex | 18 + lib/postoffice/repo.ex | 5 + lib/postoffice_web.ex | 68 + lib/postoffice_web/channels/user_socket.ex | 33 + .../controllers/api/health_controller.ex | 13 + .../controllers/api/message_controller.ex | 16 + .../controllers/api/topic_controller.ex | 25 + .../controllers/fallback_controller.ex | 22 + .../controllers/index_controller.ex | 14 + .../controllers/message_controller.ex | 28 + .../controllers/publisher_controller.ex | 57 + lib/postoffice_web/endpoint.ex | 45 + lib/postoffice_web/gettext.ex | 24 + lib/postoffice_web/router.ex | 38 + .../templates/index/index.html.eex | 54 + .../templates/layout/app.html.eex | 83 + .../templates/message/show.html.eex | 64 + .../templates/page/index.html.eex | 7 + .../templates/publisher/edit.html.eex | 27 + .../templates/publisher/index.html.eex | 27 + .../templates/publisher/new.html.eex | 34 + lib/postoffice_web/views/api/health_view.ex | 9 + lib/postoffice_web/views/api/message_view.ex | 18 + lib/postoffice_web/views/api/topic_view.ex | 25 + lib/postoffice_web/views/changeset_view.ex | 19 + lib/postoffice_web/views/error_helpers.ex | 33 + lib/postoffice_web/views/error_view.ex | 16 + lib/postoffice_web/views/index_view.ex | 3 + lib/postoffice_web/views/layout_view.ex | 3 + lib/postoffice_web/views/message_view.ex | 3 + lib/postoffice_web/views/page_view.ex | 3 + lib/postoffice_web/views/publisher_view.ex | 3 + mix.exs | 78 + mix.lock | 52 + priv/gettext/en/LC_MESSAGES/errors.po | 97 + priv/gettext/errors.pot | 95 + priv/repo/migrations/.formatter.exs | 4 + .../20191001220749_add_topic_schema.exs | 11 + .../20191002095653_create_messages.exs | 15 + .../20191010151745_create_publishers.exs | 15 + ...191013173545_create_publisher_successs.exs | 12 + ...191114231359_create_publisher_failures.exs | 12 + ...5313_add_initial_message_to_publishers.exs | 9 + .../20191120092108_remove_rate_limit.exs | 9 + ...54310_add_processed_to_message_failure.exs | 9 + ...91126114251_add_index_on_messages_uuid.exs | 7 + .../20191126120011_add_some_indexes.exs | 11 + ...d_publisher_index_on_publisher_success.exs | 7 + priv/repo/seeds.exs | 11 + secrets/dummy-credentials.json | 12 + test/postoffice/dispatch_test.exs | 51 + test/postoffice/handlers/http_test.exs | 103 + test/postoffice/handlers/pubsub_test.exs | 85 + test/postoffice/messaging_test.exs | 234 + test/postoffice/postoffice_test.exs | 11 + .../api/message_controller_test.exs | 35 + .../controllers/api/topic_controller_test.exs | 34 + test/postoffice_web/views/error_view_test.exs | 15 + test/support/channel_case.ex | 37 + test/support/conn_case.ex | 38 + test/support/data_case.ex | 53 + test/test_helper.exs | 17 + 104 files changed, 15505 insertions(+) create mode 100644 .dockerignore create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/.babelrc create mode 100644 assets/css/app.css create mode 100644 assets/css/live_view.css create mode 100644 assets/js/adminlte.js create mode 100644 assets/js/app.js create mode 100644 assets/package-lock.json create mode 100644 assets/package.json create mode 100644 assets/static/favicon.ico create mode 100644 assets/static/images/phoenix.png create mode 100644 assets/static/robots.txt create mode 100644 assets/webpack.config.js create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/dummy-credentials.json create mode 100644 config/prod.exs create mode 100644 config/releases.exs create mode 100644 config/test.exs create mode 100644 docker/docker-compose.yml create mode 100644 lib/postoffice.ex create mode 100644 lib/postoffice/adapters/http.ex create mode 100644 lib/postoffice/adapters/impl.ex create mode 100644 lib/postoffice/adapters/pubsub.ex create mode 100644 lib/postoffice/application.ex create mode 100644 lib/postoffice/dispatch.ex create mode 100644 lib/postoffice/handlers/http.ex create mode 100644 lib/postoffice/handlers/pubsub.ex create mode 100644 lib/postoffice/messages_consumer.ex create mode 100644 lib/postoffice/messages_consumer_supervisor.ex create mode 100644 lib/postoffice/messages_producer.ex create mode 100644 lib/postoffice/messages_producer_supervisor.ex create mode 100644 lib/postoffice/messaging.ex create mode 100644 lib/postoffice/messaging/message.ex create mode 100644 lib/postoffice/messaging/publisher.ex create mode 100644 lib/postoffice/messaging/publisher_failures.ex create mode 100644 lib/postoffice/messaging/publisher_success.ex create mode 100644 lib/postoffice/messaging/topic.ex create mode 100644 lib/postoffice/publisher_producer.ex create mode 100644 lib/postoffice/release.ex create mode 100644 lib/postoffice/repo.ex create mode 100644 lib/postoffice_web.ex create mode 100644 lib/postoffice_web/channels/user_socket.ex create mode 100644 lib/postoffice_web/controllers/api/health_controller.ex create mode 100644 lib/postoffice_web/controllers/api/message_controller.ex create mode 100644 lib/postoffice_web/controllers/api/topic_controller.ex create mode 100644 lib/postoffice_web/controllers/fallback_controller.ex create mode 100644 lib/postoffice_web/controllers/index_controller.ex create mode 100644 lib/postoffice_web/controllers/message_controller.ex create mode 100644 lib/postoffice_web/controllers/publisher_controller.ex create mode 100644 lib/postoffice_web/endpoint.ex create mode 100644 lib/postoffice_web/gettext.ex create mode 100644 lib/postoffice_web/router.ex create mode 100644 lib/postoffice_web/templates/index/index.html.eex create mode 100644 lib/postoffice_web/templates/layout/app.html.eex create mode 100644 lib/postoffice_web/templates/message/show.html.eex create mode 100644 lib/postoffice_web/templates/page/index.html.eex create mode 100644 lib/postoffice_web/templates/publisher/edit.html.eex create mode 100644 lib/postoffice_web/templates/publisher/index.html.eex create mode 100644 lib/postoffice_web/templates/publisher/new.html.eex create mode 100644 lib/postoffice_web/views/api/health_view.ex create mode 100644 lib/postoffice_web/views/api/message_view.ex create mode 100644 lib/postoffice_web/views/api/topic_view.ex create mode 100644 lib/postoffice_web/views/changeset_view.ex create mode 100644 lib/postoffice_web/views/error_helpers.ex create mode 100644 lib/postoffice_web/views/error_view.ex create mode 100644 lib/postoffice_web/views/index_view.ex create mode 100644 lib/postoffice_web/views/layout_view.ex create mode 100644 lib/postoffice_web/views/message_view.ex create mode 100644 lib/postoffice_web/views/page_view.ex create mode 100644 lib/postoffice_web/views/publisher_view.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/migrations/20191001220749_add_topic_schema.exs create mode 100644 priv/repo/migrations/20191002095653_create_messages.exs create mode 100644 priv/repo/migrations/20191010151745_create_publishers.exs create mode 100644 priv/repo/migrations/20191013173545_create_publisher_successs.exs create mode 100644 priv/repo/migrations/20191114231359_create_publisher_failures.exs create mode 100644 priv/repo/migrations/20191118095313_add_initial_message_to_publishers.exs create mode 100644 priv/repo/migrations/20191120092108_remove_rate_limit.exs create mode 100644 priv/repo/migrations/20191122154310_add_processed_to_message_failure.exs create mode 100644 priv/repo/migrations/20191126114251_add_index_on_messages_uuid.exs create mode 100644 priv/repo/migrations/20191126120011_add_some_indexes.exs create mode 100644 priv/repo/migrations/20191216095601_add_publisher_index_on_publisher_success.exs create mode 100644 priv/repo/seeds.exs create mode 100644 secrets/dummy-credentials.json create mode 100644 test/postoffice/dispatch_test.exs create mode 100644 test/postoffice/handlers/http_test.exs create mode 100644 test/postoffice/handlers/pubsub_test.exs create mode 100644 test/postoffice/messaging_test.exs create mode 100644 test/postoffice/postoffice_test.exs create mode 100644 test/postoffice_web/controllers/api/message_controller_test.exs create mode 100644 test/postoffice_web/controllers/api/topic_controller_test.exs create mode 100644 test/postoffice_web/views/error_view_test.exs create mode 100644 test/support/channel_case.ex create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/test_helper.exs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d3486e85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +/_build/ +/deps/ +erl_crash.dump diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 00000000..8a6391c6 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :phoenix], + inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], + subdirectories: ["priv/*/migrations"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..27d8c1f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +my_app-*.tar + +# Since we are building assets from assets/, +# we ignore priv/static. You may want to comment +# this depending on your deployment strategy. +/priv/static/ + +.elixir_ls/ + +# Production secrets +prod.secret.exs +.vscode/ +assets/node_modules +rel/ + +# User-specific stuff: +.idea/ + +Makefile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4c8fc3e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# BUILD +FROM elixir:1.9.4-alpine as build + +RUN apk add --no-cache --update make g++ nodejs npm + +RUN mix local.hex --force && \ + mix local.rebar --force + +ENV MIX_ENV prod + +WORKDIR /app + +COPY . . + +RUN mkdir /secrets +COPY config/dummy-credentials.json /secrets/credentials.json + +RUN mix deps.get --only prod && npm run deploy --prefix ./assets && mix phx.digest && mix release --quiet + +# RELEASE +FROM alpine:3.10.3 + +RUN apk add --no-cache --update bash + +RUN mkdir /secrets +WORKDIR /app + +COPY --from=build /app/_build/prod/rel/postoffice ./ + +CMD ["start"] +ENTRYPOINT ["/app/bin/postoffice"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..8351b9a6 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Postoffice + +To start your Phoenix server: + * `brew update` + * `brew install elixir` + * Create the following environmet variables in order to start the application: + * `GCLOUD_PUBSUB_CREDENTIALS_PATH` with the absolute path to the pubsub credentials file. We provide `config/dummy-credentials.json` to be able to start the app. + * `GCLOUD_PUBSUB_PROJECT_ID` with the project_id used. + * `mix local.hex` + * `mix archive.install hex phx_new 1.4.11` + * Install dependencies with `mix deps.get` + * Inside `docker` directory, run `docker-compose up -d` to start a new postgres database + * Create and migrate your database with `mix ecto.setup` + * Execute `npm install` inside `assets/` + * Start Phoenix endpoint with `mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. diff --git a/assets/.babelrc b/assets/.babelrc new file mode 100644 index 00000000..6ab075ba --- /dev/null +++ b/assets/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": [ + "env" + ] +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 00000000..0fd13a2f --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,13 @@ + +@import "../node_modules/bootstrap/dist/css/bootstrap.css"; +@import "../node_modules/admin-lte/dist/css/adminlte.css"; +@import "../node_modules/font-awesome/css/font-awesome.css"; + +.data-content { + padding: 0.5rem 1.5rem !important; +} + +.input-group.input-group-sm { + width: 200px; +} + diff --git a/assets/css/live_view.css b/assets/css/live_view.css new file mode 100644 index 00000000..f864eb96 --- /dev/null +++ b/assets/css/live_view.css @@ -0,0 +1,67 @@ +.phx-disconnected{ + cursor: wait; +} +.phx-disconnected *{ + pointer-events: none; +} +.phx-disconnected::before{ + -webkit-animation-play-state: running; + animation-play-state: running; + opacity: 1; + content: ""; + position: absolute; + top: 0; + left: 0; + background-color: rgba(255, 255, 255, .5); + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; +} +@-webkit-keyframes phx-spinner { + 0% { + -webkit-transform: translate3d(-50%, -50%, 0) rotate(0deg); + transform: translate3d(-50%, -50%, 0) rotate(0deg); + } + 100% { + -webkit-transform: translate3d(-50%, -50%, 0) rotate(360deg); + transform: translate3d(-50%, -50%, 0) rotate(360deg); + } +} +@keyframes phx-spinner { + 0% { + -webkit-transform: translate3d(-50%, -50%, 0) rotate(0deg); + transform: translate3d(-50%, -50%, 0) rotate(0deg); + } + 100% { + -webkit-transform: translate3d(-50%, -50%, 0) rotate(360deg); + transform: translate3d(-50%, -50%, 0) rotate(360deg); + } +} + +.phx-disconnected::after { + -webkit-animation: 0.8s linear infinite phx-spinner; + animation: 0.8s linear infinite phx-spinner; + -webkit-animation-play-state: inherit; + animation-play-state: inherit; + border: solid 3px #dedede; + border-bottom-color: #0069d9; + border-radius: 50%; + content: ""; + height: 40px; + left: 50%; + opacity: inherit; + position: absolute; + top: 50%; + -webkit-transform: translate3d(-50%, -50%, 0); + transform: translate3d(-50%, -50%, 0); + width: 40px; + will-change: transform; +} + + +.phx-error { + background: #ffe6f0!important; +} + diff --git a/assets/js/adminlte.js b/assets/js/adminlte.js new file mode 100644 index 00000000..23271f9c --- /dev/null +++ b/assets/js/adminlte.js @@ -0,0 +1,1835 @@ +/*! + * AdminLTE v3.0.2-pre (https://adminlte.io) + * Copyright 2014-2019 Colorlib + * Licensed under MIT (https://github.com/ColorlibHQ/AdminLTE/blob/master/LICENSE) + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(['exports'], factory) : + (global = global || self, factory(global.adminlte = {})); +}(this, (function (exports) { 'use strict'; + + /** + * -------------------------------------------- + * AdminLTE ControlSidebar.js + * License MIT + * -------------------------------------------- + */ + var ControlSidebar = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'ControlSidebar'; + var DATA_KEY = 'lte.controlsidebar'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + COLLAPSED: "collapsed" + EVENT_KEY, + EXPANDED: "expanded" + EVENT_KEY + }; + var Selector = { + CONTROL_SIDEBAR: '.control-sidebar', + CONTROL_SIDEBAR_CONTENT: '.control-sidebar-content', + DATA_TOGGLE: '[data-widget="control-sidebar"]', + CONTENT: '.content-wrapper', + HEADER: '.main-header', + FOOTER: '.main-footer' + }; + var ClassName = { + CONTROL_SIDEBAR_ANIMATE: 'control-sidebar-animate', + CONTROL_SIDEBAR_OPEN: 'control-sidebar-open', + CONTROL_SIDEBAR_SLIDE: 'control-sidebar-slide-open', + LAYOUT_FIXED: 'layout-fixed', + NAVBAR_FIXED: 'layout-navbar-fixed', + NAVBAR_SM_FIXED: 'layout-sm-navbar-fixed', + NAVBAR_MD_FIXED: 'layout-md-navbar-fixed', + NAVBAR_LG_FIXED: 'layout-lg-navbar-fixed', + NAVBAR_XL_FIXED: 'layout-xl-navbar-fixed', + FOOTER_FIXED: 'layout-footer-fixed', + FOOTER_SM_FIXED: 'layout-sm-footer-fixed', + FOOTER_MD_FIXED: 'layout-md-footer-fixed', + FOOTER_LG_FIXED: 'layout-lg-footer-fixed', + FOOTER_XL_FIXED: 'layout-xl-footer-fixed' + }; + var Default = { + controlsidebarSlide: true, + scrollbarTheme: 'os-theme-light', + scrollbarAutoHide: 'l' + }; + /** + * Class Definition + * ==================================================== + */ + + var ControlSidebar = + /*#__PURE__*/ + function () { + function ControlSidebar(element, config) { + this._element = element; + this._config = config; + + this._init(); + } // Public + + + var _proto = ControlSidebar.prototype; + + _proto.collapse = function collapse() { + // Show the control sidebar + if (this._config.controlsidebarSlide) { + $('html').addClass(ClassName.CONTROL_SIDEBAR_ANIMATE); + $('body').removeClass(ClassName.CONTROL_SIDEBAR_SLIDE).delay(300).queue(function () { + $(Selector.CONTROL_SIDEBAR).hide(); + $('html').removeClass(ClassName.CONTROL_SIDEBAR_ANIMATE); + $(this).dequeue(); + }); + } else { + $('body').removeClass(ClassName.CONTROL_SIDEBAR_OPEN); + } + + var collapsedEvent = $.Event(Event.COLLAPSED); + $(this._element).trigger(collapsedEvent); + }; + + _proto.show = function show() { + // Collapse the control sidebar + if (this._config.controlsidebarSlide) { + $('html').addClass(ClassName.CONTROL_SIDEBAR_ANIMATE); + $(Selector.CONTROL_SIDEBAR).show().delay(10).queue(function () { + $('body').addClass(ClassName.CONTROL_SIDEBAR_SLIDE).delay(300).queue(function () { + $('html').removeClass(ClassName.CONTROL_SIDEBAR_ANIMATE); + $(this).dequeue(); + }); + $(this).dequeue(); + }); + } else { + $('body').addClass(ClassName.CONTROL_SIDEBAR_OPEN); + } + + var expandedEvent = $.Event(Event.EXPANDED); + $(this._element).trigger(expandedEvent); + }; + + _proto.toggle = function toggle() { + var shouldClose = $('body').hasClass(ClassName.CONTROL_SIDEBAR_OPEN) || $('body').hasClass(ClassName.CONTROL_SIDEBAR_SLIDE); + + if (shouldClose) { + // Close the control sidebar + this.collapse(); + } else { + // Open the control sidebar + this.show(); + } + } // Private + ; + + _proto._init = function _init() { + var _this = this; + + this._fixHeight(); + + this._fixScrollHeight(); + + $(window).resize(function () { + _this._fixHeight(); + + _this._fixScrollHeight(); + }); + $(window).scroll(function () { + if ($('body').hasClass(ClassName.CONTROL_SIDEBAR_OPEN) || $('body').hasClass(ClassName.CONTROL_SIDEBAR_SLIDE)) { + _this._fixScrollHeight(); + } + }); + }; + + _proto._fixScrollHeight = function _fixScrollHeight() { + var heights = { + scroll: $(document).height(), + window: $(window).height(), + header: $(Selector.HEADER).outerHeight(), + footer: $(Selector.FOOTER).outerHeight() + }; + var positions = { + bottom: Math.abs(heights.window + $(window).scrollTop() - heights.scroll), + top: $(window).scrollTop() + }; + var navbarFixed = false; + var footerFixed = false; + + if ($('body').hasClass(ClassName.LAYOUT_FIXED)) { + if ($('body').hasClass(ClassName.NAVBAR_FIXED) || $('body').hasClass(ClassName.NAVBAR_SM_FIXED) || $('body').hasClass(ClassName.NAVBAR_MD_FIXED) || $('body').hasClass(ClassName.NAVBAR_LG_FIXED) || $('body').hasClass(ClassName.NAVBAR_XL_FIXED)) { + if ($(Selector.HEADER).css("position") === "fixed") { + navbarFixed = true; + } + } + + if ($('body').hasClass(ClassName.FOOTER_FIXED) || $('body').hasClass(ClassName.FOOTER_SM_FIXED) || $('body').hasClass(ClassName.FOOTER_MD_FIXED) || $('body').hasClass(ClassName.FOOTER_LG_FIXED) || $('body').hasClass(ClassName.FOOTER_XL_FIXED)) { + if ($(Selector.FOOTER).css("position") === "fixed") { + footerFixed = true; + } + } + + if (positions.top === 0 && positions.bottom === 0) { + $(Selector.CONTROL_SIDEBAR).css('bottom', heights.footer); + $(Selector.CONTROL_SIDEBAR).css('top', heights.header); + $(Selector.CONTROL_SIDEBAR + ', ' + Selector.CONTROL_SIDEBAR + ' ' + Selector.CONTROL_SIDEBAR_CONTENT).css('height', heights.window - (heights.header + heights.footer)); + } else if (positions.bottom <= heights.footer) { + if (footerFixed === false) { + $(Selector.CONTROL_SIDEBAR).css('bottom', heights.footer - positions.bottom); + $(Selector.CONTROL_SIDEBAR + ', ' + Selector.CONTROL_SIDEBAR + ' ' + Selector.CONTROL_SIDEBAR_CONTENT).css('height', heights.window - (heights.footer - positions.bottom)); + } else { + $(Selector.CONTROL_SIDEBAR).css('bottom', heights.footer); + } + } else if (positions.top <= heights.header) { + if (navbarFixed === false) { + $(Selector.CONTROL_SIDEBAR).css('top', heights.header - positions.top); + $(Selector.CONTROL_SIDEBAR + ', ' + Selector.CONTROL_SIDEBAR + ' ' + Selector.CONTROL_SIDEBAR_CONTENT).css('height', heights.window - (heights.header - positions.top)); + } else { + $(Selector.CONTROL_SIDEBAR).css('top', heights.header); + } + } else { + if (navbarFixed === false) { + $(Selector.CONTROL_SIDEBAR).css('top', 0); + $(Selector.CONTROL_SIDEBAR + ', ' + Selector.CONTROL_SIDEBAR + ' ' + Selector.CONTROL_SIDEBAR_CONTENT).css('height', heights.window); + } else { + $(Selector.CONTROL_SIDEBAR).css('top', heights.header); + } + } + } + }; + + _proto._fixHeight = function _fixHeight() { + var heights = { + window: $(window).height(), + header: $(Selector.HEADER).outerHeight(), + footer: $(Selector.FOOTER).outerHeight() + }; + + if ($('body').hasClass(ClassName.LAYOUT_FIXED)) { + var sidebarHeight = heights.window - heights.header; + + if ($('body').hasClass(ClassName.FOOTER_FIXED) || $('body').hasClass(ClassName.FOOTER_SM_FIXED) || $('body').hasClass(ClassName.FOOTER_MD_FIXED) || $('body').hasClass(ClassName.FOOTER_LG_FIXED) || $('body').hasClass(ClassName.FOOTER_XL_FIXED)) { + if ($(Selector.FOOTER).css("position") === "fixed") { + sidebarHeight = heights.window - heights.header - heights.footer; + } + } + + $(Selector.CONTROL_SIDEBAR + ' ' + Selector.CONTROL_SIDEBAR_CONTENT).css('height', sidebarHeight); + + if (typeof $.fn.overlayScrollbars !== 'undefined') { + $(Selector.CONTROL_SIDEBAR + ' ' + Selector.CONTROL_SIDEBAR_CONTENT).overlayScrollbars({ + className: this._config.scrollbarTheme, + sizeAutoCapable: true, + scrollbars: { + autoHide: this._config.scrollbarAutoHide, + clickScrolling: true + } + }); + } + } + } // Static + ; + + ControlSidebar._jQueryInterface = function _jQueryInterface(operation) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new ControlSidebar(this, _options); + $(this).data(DATA_KEY, data); + } + + if (data[operation] === 'undefined') { + throw new Error(operation + " is not a function"); + } + + data[operation](); + }); + }; + + return ControlSidebar; + }(); + /** + * + * Data Api implementation + * ==================================================== + */ + + + $(document).on('click', Selector.DATA_TOGGLE, function (event) { + event.preventDefault(); + + ControlSidebar._jQueryInterface.call($(this), 'toggle'); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = ControlSidebar._jQueryInterface; + $.fn[NAME].Constructor = ControlSidebar; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return ControlSidebar._jQueryInterface; + }; + + return ControlSidebar; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE Layout.js + * License MIT + * -------------------------------------------- + */ + var Layout = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'Layout'; + var DATA_KEY = 'lte.layout'; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Selector = { + HEADER: '.main-header', + MAIN_SIDEBAR: '.main-sidebar', + SIDEBAR: '.main-sidebar .sidebar', + CONTENT: '.content-wrapper', + BRAND: '.brand-link', + CONTENT_HEADER: '.content-header', + WRAPPER: '.wrapper', + CONTROL_SIDEBAR: '.control-sidebar', + CONTROL_SIDEBAR_CONTENT: '.control-sidebar-content', + CONTROL_SIDEBAR_BTN: '[data-widget="control-sidebar"]', + LAYOUT_FIXED: '.layout-fixed', + FOOTER: '.main-footer', + PUSHMENU_BTN: '[data-widget="pushmenu"]', + LOGIN_BOX: '.login-box', + REGISTER_BOX: '.register-box' + }; + var ClassName = { + HOLD: 'hold-transition', + SIDEBAR: 'main-sidebar', + CONTENT_FIXED: 'content-fixed', + SIDEBAR_FOCUSED: 'sidebar-focused', + LAYOUT_FIXED: 'layout-fixed', + NAVBAR_FIXED: 'layout-navbar-fixed', + FOOTER_FIXED: 'layout-footer-fixed', + LOGIN_PAGE: 'login-page', + REGISTER_PAGE: 'register-page', + CONTROL_SIDEBAR_SLIDE_OPEN: 'control-sidebar-slide-open', + CONTROL_SIDEBAR_OPEN: 'control-sidebar-open' + }; + var Default = { + scrollbarTheme: 'os-theme-light', + scrollbarAutoHide: 'l' + }; + /** + * Class Definition + * ==================================================== + */ + + var Layout = + /*#__PURE__*/ + function () { + function Layout(element, config) { + this._config = config; + this._element = element; + + this._init(); + } // Public + + + var _proto = Layout.prototype; + + _proto.fixLayoutHeight = function fixLayoutHeight(extra) { + if (extra === void 0) { + extra = null; + } + + var control_sidebar = 0; + + if ($('body').hasClass(ClassName.CONTROL_SIDEBAR_SLIDE_OPEN) || $('body').hasClass(ClassName.CONTROL_SIDEBAR_OPEN) || extra == 'control_sidebar') { + control_sidebar = $(Selector.CONTROL_SIDEBAR_CONTENT).height(); + } + + var heights = { + window: $(window).height(), + header: $(Selector.HEADER).length !== 0 ? $(Selector.HEADER).outerHeight() : 0, + footer: $(Selector.FOOTER).length !== 0 ? $(Selector.FOOTER).outerHeight() : 0, + sidebar: $(Selector.SIDEBAR).length !== 0 ? $(Selector.SIDEBAR).height() : 0, + control_sidebar: control_sidebar + }; + + var max = this._max(heights); + + if (max == heights.control_sidebar) { + $(Selector.CONTENT).css('min-height', max); + } else if (max == heights.window) { + $(Selector.CONTENT).css('min-height', max - heights.header - heights.footer); + } else { + $(Selector.CONTENT).css('min-height', max - heights.header); + } + + if ($('body').hasClass(ClassName.LAYOUT_FIXED)) { + $(Selector.CONTENT).css('min-height', max - heights.header - heights.footer); + + if (typeof $.fn.overlayScrollbars !== 'undefined') { + $(Selector.SIDEBAR).overlayScrollbars({ + className: this._config.scrollbarTheme, + sizeAutoCapable: true, + scrollbars: { + autoHide: this._config.scrollbarAutoHide, + clickScrolling: true + } + }); + } + } + } // Private + ; + + _proto._init = function _init() { + var _this = this; + + // Activate layout height watcher + this.fixLayoutHeight(); + $(Selector.SIDEBAR).on('collapsed.lte.treeview expanded.lte.treeview', function () { + _this.fixLayoutHeight(); + }); + $(Selector.PUSHMENU_BTN).on('collapsed.lte.pushmenu shown.lte.pushmenu', function () { + _this.fixLayoutHeight(); + }); + $(Selector.CONTROL_SIDEBAR_BTN).on('collapsed.lte.controlsidebar', function () { + _this.fixLayoutHeight(); + }).on('expanded.lte.controlsidebar', function () { + _this.fixLayoutHeight('control_sidebar'); + }); + $(window).resize(function () { + _this.fixLayoutHeight(); + }); + + if (!$('body').hasClass(ClassName.LOGIN_PAGE) && !$('body').hasClass(ClassName.REGISTER_PAGE)) { + $('body, html').css('height', 'auto'); + } else if ($('body').hasClass(ClassName.LOGIN_PAGE) || $('body').hasClass(ClassName.REGISTER_PAGE)) { + var box_height = $(Selector.LOGIN_BOX + ', ' + Selector.REGISTER_BOX).height(); + $('body').css('min-height', box_height); + } + + $('body.hold-transition').removeClass('hold-transition'); + }; + + _proto._max = function _max(numbers) { + // Calculate the maximum number in a list + var max = 0; + Object.keys(numbers).forEach(function (key) { + if (numbers[key] > max) { + max = numbers[key]; + } + }); + return max; + } // Static + ; + + Layout._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new Layout($(this), _options); + $(this).data(DATA_KEY, data); + } + + if (config === 'init') { + data[config](); + } + }); + }; + + return Layout; + }(); + /** + * Data API + * ==================================================== + */ + + + $(window).on('load', function () { + Layout._jQueryInterface.call($('body')); + }); + $(Selector.SIDEBAR + ' a').on('focusin', function () { + $(Selector.MAIN_SIDEBAR).addClass(ClassName.SIDEBAR_FOCUSED); + }); + $(Selector.SIDEBAR + ' a').on('focusout', function () { + $(Selector.MAIN_SIDEBAR).removeClass(ClassName.SIDEBAR_FOCUSED); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = Layout._jQueryInterface; + $.fn[NAME].Constructor = Layout; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return Layout._jQueryInterface; + }; + + return Layout; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE PushMenu.js + * License MIT + * -------------------------------------------- + */ + var PushMenu = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'PushMenu'; + var DATA_KEY = 'lte.pushmenu'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + COLLAPSED: "collapsed" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY + }; + var Default = { + autoCollapseSize: 992, + enableRemember: false, + noTransitionAfterReload: true + }; + var Selector = { + TOGGLE_BUTTON: '[data-widget="pushmenu"]', + SIDEBAR_MINI: '.sidebar-mini', + SIDEBAR_COLLAPSED: '.sidebar-collapse', + BODY: 'body', + OVERLAY: '#sidebar-overlay', + WRAPPER: '.wrapper' + }; + var ClassName = { + SIDEBAR_OPEN: 'sidebar-open', + COLLAPSED: 'sidebar-collapse', + OPEN: 'sidebar-open' + }; + /** + * Class Definition + * ==================================================== + */ + + var PushMenu = + /*#__PURE__*/ + function () { + function PushMenu(element, options) { + this._element = element; + this._options = $.extend({}, Default, options); + + if (!$(Selector.OVERLAY).length) { + this._addOverlay(); + } + + this._init(); + } // Public + + + var _proto = PushMenu.prototype; + + _proto.expand = function expand() { + if (this._options.autoCollapseSize) { + if ($(window).width() <= this._options.autoCollapseSize) { + $(Selector.BODY).addClass(ClassName.OPEN); + } + } + + $(Selector.BODY).removeClass(ClassName.COLLAPSED); + + if (this._options.enableRemember) { + localStorage.setItem("remember" + EVENT_KEY, ClassName.OPEN); + } + + var shownEvent = $.Event(Event.SHOWN); + $(this._element).trigger(shownEvent); + }; + + _proto.collapse = function collapse() { + if (this._options.autoCollapseSize) { + if ($(window).width() <= this._options.autoCollapseSize) { + $(Selector.BODY).removeClass(ClassName.OPEN); + } + } + + $(Selector.BODY).addClass(ClassName.COLLAPSED); + + if (this._options.enableRemember) { + localStorage.setItem("remember" + EVENT_KEY, ClassName.COLLAPSED); + } + + var collapsedEvent = $.Event(Event.COLLAPSED); + $(this._element).trigger(collapsedEvent); + }; + + _proto.toggle = function toggle() { + if (!$(Selector.BODY).hasClass(ClassName.COLLAPSED)) { + this.collapse(); + } else { + this.expand(); + } + }; + + _proto.autoCollapse = function autoCollapse(resize) { + if (resize === void 0) { + resize = false; + } + + if (this._options.autoCollapseSize) { + if ($(window).width() <= this._options.autoCollapseSize) { + if (!$(Selector.BODY).hasClass(ClassName.OPEN)) { + this.collapse(); + } + } else if (resize == true) { + if ($(Selector.BODY).hasClass(ClassName.OPEN)) { + $(Selector.BODY).removeClass(ClassName.OPEN); + } + } + } + }; + + _proto.remember = function remember() { + if (this._options.enableRemember) { + var toggleState = localStorage.getItem("remember" + EVENT_KEY); + + if (toggleState == ClassName.COLLAPSED) { + if (this._options.noTransitionAfterReload) { + $("body").addClass('hold-transition').addClass(ClassName.COLLAPSED).delay(50).queue(function () { + $(this).removeClass('hold-transition'); + $(this).dequeue(); + }); + } else { + $("body").addClass(ClassName.COLLAPSED); + } + } else { + if (this._options.noTransitionAfterReload) { + $("body").addClass('hold-transition').removeClass(ClassName.COLLAPSED).delay(50).queue(function () { + $(this).removeClass('hold-transition'); + $(this).dequeue(); + }); + } else { + $("body").removeClass(ClassName.COLLAPSED); + } + } + } + } // Private + ; + + _proto._init = function _init() { + var _this = this; + + this.remember(); + this.autoCollapse(); + $(window).resize(function () { + _this.autoCollapse(true); + }); + }; + + _proto._addOverlay = function _addOverlay() { + var _this2 = this; + + var overlay = $('
', { + id: 'sidebar-overlay' + }); + overlay.on('click', function () { + _this2.collapse(); + }); + $(Selector.WRAPPER).append(overlay); + } // Static + ; + + PushMenu._jQueryInterface = function _jQueryInterface(operation) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new PushMenu(this, _options); + $(this).data(DATA_KEY, data); + } + + if (typeof operation === 'string' && operation.match(/collapse|expand|toggle/)) { + data[operation](); + } + }); + }; + + return PushMenu; + }(); + /** + * Data API + * ==================================================== + */ + + + $(document).on('click', Selector.TOGGLE_BUTTON, function (event) { + event.preventDefault(); + var button = event.currentTarget; + + if ($(button).data('widget') !== 'pushmenu') { + button = $(button).closest(Selector.TOGGLE_BUTTON); + } + + PushMenu._jQueryInterface.call($(button), 'toggle'); + }); + $(window).on('load', function () { + PushMenu._jQueryInterface.call($(Selector.TOGGLE_BUTTON)); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = PushMenu._jQueryInterface; + $.fn[NAME].Constructor = PushMenu; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return PushMenu._jQueryInterface; + }; + + return PushMenu; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE Treeview.js + * License MIT + * -------------------------------------------- + */ + var Treeview = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'Treeview'; + var DATA_KEY = 'lte.treeview'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + SELECTED: "selected" + EVENT_KEY, + EXPANDED: "expanded" + EVENT_KEY, + COLLAPSED: "collapsed" + EVENT_KEY, + LOAD_DATA_API: "load" + EVENT_KEY + }; + var Selector = { + LI: '.nav-item', + LINK: '.nav-link', + TREEVIEW_MENU: '.nav-treeview', + OPEN: '.menu-open', + DATA_WIDGET: '[data-widget="treeview"]' + }; + var ClassName = { + LI: 'nav-item', + LINK: 'nav-link', + TREEVIEW_MENU: 'nav-treeview', + OPEN: 'menu-open', + SIDEBAR_COLLAPSED: 'sidebar-collapse' + }; + var Default = { + trigger: Selector.DATA_WIDGET + " " + Selector.LINK, + animationSpeed: 300, + accordion: true, + expandSidebar: false, + sidebarButtonSelector: '[data-widget="pushmenu"]' + }; + /** + * Class Definition + * ==================================================== + */ + + var Treeview = + /*#__PURE__*/ + function () { + function Treeview(element, config) { + this._config = config; + this._element = element; + } // Public + + + var _proto = Treeview.prototype; + + _proto.init = function init() { + this._setupListeners(); + }; + + _proto.expand = function expand(treeviewMenu, parentLi) { + var _this = this; + + var expandedEvent = $.Event(Event.EXPANDED); + + if (this._config.accordion) { + var openMenuLi = parentLi.siblings(Selector.OPEN).first(); + var openTreeview = openMenuLi.find(Selector.TREEVIEW_MENU).first(); + this.collapse(openTreeview, openMenuLi); + } + + treeviewMenu.stop().slideDown(this._config.animationSpeed, function () { + parentLi.addClass(ClassName.OPEN); + $(_this._element).trigger(expandedEvent); + }); + + if (this._config.expandSidebar) { + this._expandSidebar(); + } + }; + + _proto.collapse = function collapse(treeviewMenu, parentLi) { + var _this2 = this; + + var collapsedEvent = $.Event(Event.COLLAPSED); + treeviewMenu.stop().slideUp(this._config.animationSpeed, function () { + parentLi.removeClass(ClassName.OPEN); + $(_this2._element).trigger(collapsedEvent); + treeviewMenu.find(Selector.OPEN + " > " + Selector.TREEVIEW_MENU).slideUp(); + treeviewMenu.find(Selector.OPEN).removeClass(ClassName.OPEN); + }); + }; + + _proto.toggle = function toggle(event) { + var $relativeTarget = $(event.currentTarget); + var $parent = $relativeTarget.parent(); + var treeviewMenu = $parent.find('> ' + Selector.TREEVIEW_MENU); + + if (!treeviewMenu.is(Selector.TREEVIEW_MENU)) { + if (!$parent.is(Selector.LI)) { + treeviewMenu = $parent.parent().find('> ' + Selector.TREEVIEW_MENU); + } + + if (!treeviewMenu.is(Selector.TREEVIEW_MENU)) { + return; + } + } + + event.preventDefault(); + var parentLi = $relativeTarget.parents(Selector.LI).first(); + var isOpen = parentLi.hasClass(ClassName.OPEN); + + if (isOpen) { + this.collapse($(treeviewMenu), parentLi); + } else { + this.expand($(treeviewMenu), parentLi); + } + } // Private + ; + + _proto._setupListeners = function _setupListeners() { + var _this3 = this; + + $(document).on('click', this._config.trigger, function (event) { + _this3.toggle(event); + }); + }; + + _proto._expandSidebar = function _expandSidebar() { + if ($('body').hasClass(ClassName.SIDEBAR_COLLAPSED)) { + $(this._config.sidebarButtonSelector).PushMenu('expand'); + } + } // Static + ; + + Treeview._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new Treeview($(this), _options); + $(this).data(DATA_KEY, data); + } + + if (config === 'init') { + data[config](); + } + }); + }; + + return Treeview; + }(); + /** + * Data API + * ==================================================== + */ + + + $(window).on(Event.LOAD_DATA_API, function () { + $(Selector.DATA_WIDGET).each(function () { + Treeview._jQueryInterface.call($(this), 'init'); + }); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = Treeview._jQueryInterface; + $.fn[NAME].Constructor = Treeview; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return Treeview._jQueryInterface; + }; + + return Treeview; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE DirectChat.js + * License MIT + * -------------------------------------------- + */ + var DirectChat = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'DirectChat'; + var DATA_KEY = 'lte.directchat'; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + TOGGLED: "toggled{EVENT_KEY}" + }; + var Selector = { + DATA_TOGGLE: '[data-widget="chat-pane-toggle"]', + DIRECT_CHAT: '.direct-chat' + }; + var ClassName = { + DIRECT_CHAT_OPEN: 'direct-chat-contacts-open' + }; + /** + * Class Definition + * ==================================================== + */ + + var DirectChat = + /*#__PURE__*/ + function () { + function DirectChat(element, config) { + this._element = element; + } + + var _proto = DirectChat.prototype; + + _proto.toggle = function toggle() { + $(this._element).parents(Selector.DIRECT_CHAT).first().toggleClass(ClassName.DIRECT_CHAT_OPEN); + var toggledEvent = $.Event(Event.TOGGLED); + $(this._element).trigger(toggledEvent); + } // Static + ; + + DirectChat._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + if (!data) { + data = new DirectChat($(this)); + $(this).data(DATA_KEY, data); + } + + data[config](); + }); + }; + + return DirectChat; + }(); + /** + * + * Data Api implementation + * ==================================================== + */ + + + $(document).on('click', Selector.DATA_TOGGLE, function (event) { + if (event) event.preventDefault(); + + DirectChat._jQueryInterface.call($(this), 'toggle'); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = DirectChat._jQueryInterface; + $.fn[NAME].Constructor = DirectChat; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return DirectChat._jQueryInterface; + }; + + return DirectChat; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE TodoList.js + * License MIT + * -------------------------------------------- + */ + var TodoList = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'TodoList'; + var DATA_KEY = 'lte.todolist'; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Selector = { + DATA_TOGGLE: '[data-widget="todo-list"]' + }; + var ClassName = { + TODO_LIST_DONE: 'done' + }; + var Default = { + onCheck: function onCheck(item) { + return item; + }, + onUnCheck: function onUnCheck(item) { + return item; + } + }; + /** + * Class Definition + * ==================================================== + */ + + var TodoList = + /*#__PURE__*/ + function () { + function TodoList(element, config) { + this._config = config; + this._element = element; + + this._init(); + } // Public + + + var _proto = TodoList.prototype; + + _proto.toggle = function toggle(item) { + item.parents('li').toggleClass(ClassName.TODO_LIST_DONE); + + if (!$(item).prop('checked')) { + this.unCheck($(item)); + return; + } + + this.check(item); + }; + + _proto.check = function check(item) { + this._config.onCheck.call(item); + }; + + _proto.unCheck = function unCheck(item) { + this._config.onUnCheck.call(item); + } // Private + ; + + _proto._init = function _init() { + var that = this; + $(Selector.DATA_TOGGLE).find('input:checkbox:checked').parents('li').toggleClass(ClassName.TODO_LIST_DONE); + $(Selector.DATA_TOGGLE).on('change', 'input:checkbox', function (event) { + that.toggle($(event.target)); + }); + } // Static + ; + + TodoList._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new TodoList($(this), _options); + $(this).data(DATA_KEY, data); + } + + if (config === 'init') { + data[config](); + } + }); + }; + + return TodoList; + }(); + /** + * Data API + * ==================================================== + */ + + + $(window).on('load', function () { + TodoList._jQueryInterface.call($(Selector.DATA_TOGGLE)); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = TodoList._jQueryInterface; + $.fn[NAME].Constructor = TodoList; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return TodoList._jQueryInterface; + }; + + return TodoList; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE CardWidget.js + * License MIT + * -------------------------------------------- + */ + var CardWidget = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'CardWidget'; + var DATA_KEY = 'lte.cardwidget'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + EXPANDED: "expanded" + EVENT_KEY, + COLLAPSED: "collapsed" + EVENT_KEY, + MAXIMIZED: "maximized" + EVENT_KEY, + MINIMIZED: "minimized" + EVENT_KEY, + REMOVED: "removed" + EVENT_KEY + }; + var ClassName = { + CARD: 'card', + COLLAPSED: 'collapsed-card', + WAS_COLLAPSED: 'was-collapsed', + MAXIMIZED: 'maximized-card' + }; + var Selector = { + DATA_REMOVE: '[data-card-widget="remove"]', + DATA_COLLAPSE: '[data-card-widget="collapse"]', + DATA_MAXIMIZE: '[data-card-widget="maximize"]', + CARD: "." + ClassName.CARD, + CARD_HEADER: '.card-header', + CARD_BODY: '.card-body', + CARD_FOOTER: '.card-footer', + COLLAPSED: "." + ClassName.COLLAPSED + }; + var Default = { + animationSpeed: 'normal', + collapseTrigger: Selector.DATA_COLLAPSE, + removeTrigger: Selector.DATA_REMOVE, + maximizeTrigger: Selector.DATA_MAXIMIZE, + collapseIcon: 'fa-minus', + expandIcon: 'fa-plus', + maximizeIcon: 'fa-expand', + minimizeIcon: 'fa-compress' + }; + + var CardWidget = + /*#__PURE__*/ + function () { + function CardWidget(element, settings) { + this._element = element; + this._parent = element.parents(Selector.CARD).first(); + + if (element.hasClass(ClassName.CARD)) { + this._parent = element; + } + + this._settings = $.extend({}, Default, settings); + } + + var _proto = CardWidget.prototype; + + _proto.collapse = function collapse() { + var _this = this; + + this._parent.children(Selector.CARD_BODY + ", " + Selector.CARD_FOOTER).slideUp(this._settings.animationSpeed, function () { + _this._parent.addClass(ClassName.COLLAPSED); + }); + + this._parent.find(this._settings.collapseTrigger + ' .' + this._settings.collapseIcon).addClass(this._settings.expandIcon).removeClass(this._settings.collapseIcon); + + var collapsed = $.Event(Event.COLLAPSED); + + this._element.trigger(collapsed, this._parent); + }; + + _proto.expand = function expand() { + var _this2 = this; + + this._parent.children(Selector.CARD_BODY + ", " + Selector.CARD_FOOTER).slideDown(this._settings.animationSpeed, function () { + _this2._parent.removeClass(ClassName.COLLAPSED); + }); + + this._parent.find(this._settings.collapseTrigger + ' .' + this._settings.expandIcon).addClass(this._settings.collapseIcon).removeClass(this._settings.expandIcon); + + var expanded = $.Event(Event.EXPANDED); + + this._element.trigger(expanded, this._parent); + }; + + _proto.remove = function remove() { + this._parent.slideUp(); + + var removed = $.Event(Event.REMOVED); + + this._element.trigger(removed, this._parent); + }; + + _proto.toggle = function toggle() { + if (this._parent.hasClass(ClassName.COLLAPSED)) { + this.expand(); + return; + } + + this.collapse(); + }; + + _proto.maximize = function maximize() { + this._parent.find(this._settings.maximizeTrigger + ' .' + this._settings.maximizeIcon).addClass(this._settings.minimizeIcon).removeClass(this._settings.maximizeIcon); + + this._parent.css({ + 'height': this._parent.height(), + 'width': this._parent.width(), + 'transition': 'all .15s' + }).delay(150).queue(function () { + $(this).addClass(ClassName.MAXIMIZED); + $('html').addClass(ClassName.MAXIMIZED); + + if ($(this).hasClass(ClassName.COLLAPSED)) { + $(this).addClass(ClassName.WAS_COLLAPSED); + } + + $(this).dequeue(); + }); + + var maximized = $.Event(Event.MAXIMIZED); + + this._element.trigger(maximized, this._parent); + }; + + _proto.minimize = function minimize() { + this._parent.find(this._settings.maximizeTrigger + ' .' + this._settings.minimizeIcon).addClass(this._settings.maximizeIcon).removeClass(this._settings.minimizeIcon); + + this._parent.css('cssText', 'height:' + this._parent[0].style.height + ' !important;' + 'width:' + this._parent[0].style.width + ' !important; transition: all .15s;').delay(10).queue(function () { + $(this).removeClass(ClassName.MAXIMIZED); + $('html').removeClass(ClassName.MAXIMIZED); + $(this).css({ + 'height': 'inherit', + 'width': 'inherit' + }); + + if ($(this).hasClass(ClassName.WAS_COLLAPSED)) { + $(this).removeClass(ClassName.WAS_COLLAPSED); + } + + $(this).dequeue(); + }); + + var MINIMIZED = $.Event(Event.MINIMIZED); + + this._element.trigger(MINIMIZED, this._parent); + }; + + _proto.toggleMaximize = function toggleMaximize() { + if (this._parent.hasClass(ClassName.MAXIMIZED)) { + this.minimize(); + return; + } + + this.maximize(); + } // Private + ; + + _proto._init = function _init(card) { + var _this3 = this; + + this._parent = card; + $(this).find(this._settings.collapseTrigger).click(function () { + _this3.toggle(); + }); + $(this).find(this._settings.maximizeTrigger).click(function () { + _this3.toggleMaximize(); + }); + $(this).find(this._settings.removeTrigger).click(function () { + _this3.remove(); + }); + } // Static + ; + + CardWidget._jQueryInterface = function _jQueryInterface(config) { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new CardWidget($(this), _options); + $(this).data(DATA_KEY, typeof config === 'string' ? data : config); + } + + if (typeof config === 'string' && config.match(/collapse|expand|remove|toggle|maximize|minimize|toggleMaximize/)) { + data[config](); + } else if (typeof config === 'object') { + data._init($(this)); + } + }; + + return CardWidget; + }(); + /** + * Data API + * ==================================================== + */ + + + $(document).on('click', Selector.DATA_COLLAPSE, function (event) { + if (event) { + event.preventDefault(); + } + + CardWidget._jQueryInterface.call($(this), 'toggle'); + }); + $(document).on('click', Selector.DATA_REMOVE, function (event) { + if (event) { + event.preventDefault(); + } + + CardWidget._jQueryInterface.call($(this), 'remove'); + }); + $(document).on('click', Selector.DATA_MAXIMIZE, function (event) { + if (event) { + event.preventDefault(); + } + + CardWidget._jQueryInterface.call($(this), 'toggleMaximize'); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = CardWidget._jQueryInterface; + $.fn[NAME].Constructor = CardWidget; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return CardWidget._jQueryInterface; + }; + + return CardWidget; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE CardRefresh.js + * License MIT + * -------------------------------------------- + */ + var CardRefresh = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'CardRefresh'; + var DATA_KEY = 'lte.cardrefresh'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + LOADED: "loaded" + EVENT_KEY, + OVERLAY_ADDED: "overlay.added" + EVENT_KEY, + OVERLAY_REMOVED: "overlay.removed" + EVENT_KEY + }; + var ClassName = { + CARD: 'card' + }; + var Selector = { + CARD: "." + ClassName.CARD, + DATA_REFRESH: '[data-card-widget="card-refresh"]' + }; + var Default = { + source: '', + sourceSelector: '', + params: {}, + trigger: Selector.DATA_REFRESH, + content: '.card-body', + loadInContent: true, + loadOnInit: true, + responseType: '', + overlayTemplate: '
', + onLoadStart: function onLoadStart() {}, + onLoadDone: function onLoadDone(response) { + return response; + } + }; + + var CardRefresh = + /*#__PURE__*/ + function () { + function CardRefresh(element, settings) { + this._element = element; + this._parent = element.parents(Selector.CARD).first(); + this._settings = $.extend({}, Default, settings); + this._overlay = $(this._settings.overlayTemplate); + + if (element.hasClass(ClassName.CARD)) { + this._parent = element; + } + + if (this._settings.source === '') { + throw new Error('Source url was not defined. Please specify a url in your CardRefresh source option.'); + } + + this._init(); + + if (this._settings.loadOnInit) { + this.load(); + } + } + + var _proto = CardRefresh.prototype; + + _proto.load = function load() { + this._addOverlay(); + + this._settings.onLoadStart.call($(this)); + + $.get(this._settings.source, this._settings.params, function (response) { + if (this._settings.loadInContent) { + if (this._settings.sourceSelector != '') { + response = $(response).find(this._settings.sourceSelector).html(); + } + + this._parent.find(this._settings.content).html(response); + } + + this._settings.onLoadDone.call($(this), response); + + this._removeOverlay(); + }.bind(this), this._settings.responseType !== '' && this._settings.responseType); + var loadedEvent = $.Event(Event.LOADED); + $(this._element).trigger(loadedEvent); + }; + + _proto._addOverlay = function _addOverlay() { + this._parent.append(this._overlay); + + var overlayAddedEvent = $.Event(Event.OVERLAY_ADDED); + $(this._element).trigger(overlayAddedEvent); + }; + + _proto._removeOverlay = function _removeOverlay() { + this._parent.find(this._overlay).remove(); + + var overlayRemovedEvent = $.Event(Event.OVERLAY_REMOVED); + $(this._element).trigger(overlayRemovedEvent); + }; + + // Private + _proto._init = function _init(card) { + var _this = this; + + $(this).find(this._settings.trigger).on('click', function () { + _this.load(); + }); + } // Static + ; + + CardRefresh._jQueryInterface = function _jQueryInterface(config) { + var data = $(this).data(DATA_KEY); + + var _options = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new CardRefresh($(this), _options); + $(this).data(DATA_KEY, typeof config === 'string' ? data : config); + } + + if (typeof config === 'string' && config.match(/load/)) { + data[config](); + } else if (typeof config === 'object') { + data._init($(this)); + } + }; + + return CardRefresh; + }(); + /** + * Data API + * ==================================================== + */ + + + $(document).on('click', Selector.DATA_REFRESH, function (event) { + if (event) { + event.preventDefault(); + } + + CardRefresh._jQueryInterface.call($(this), 'load'); + }); + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = CardRefresh._jQueryInterface; + $.fn[NAME].Constructor = CardRefresh; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return CardRefresh._jQueryInterface; + }; + + return CardRefresh; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE Dropdown.js + * License MIT + * -------------------------------------------- + */ + var Dropdown = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'Dropdown'; + var DATA_KEY = 'lte.dropdown'; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Selector = { + DROPDOWN_MENU: 'ul.dropdown-menu', + DROPDOWN_TOGGLE: '[data-toggle="dropdown"]' + }; + var Default = {}; + /** + * Class Definition + * ==================================================== + */ + + var Dropdown = + /*#__PURE__*/ + function () { + function Dropdown(element, config) { + this._config = config; + this._element = element; + } // Public + + + var _proto = Dropdown.prototype; + + _proto.toggleSubmenu = function toggleSubmenu() { + this._element.siblings().show().toggleClass("show"); + + if (!this._element.next().hasClass('show')) { + this._element.parents('.dropdown-menu').first().find('.show').removeClass("show").hide(); + } + + this._element.parents('li.nav-item.dropdown.show').on('hidden.bs.dropdown', function (e) { + $('.dropdown-submenu .show').removeClass("show").hide(); + }); + } // Static + ; + + Dropdown._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $(this).data(DATA_KEY); + + var _config = $.extend({}, Default, $(this).data()); + + if (!data) { + data = new Dropdown($(this), _config); + $(this).data(DATA_KEY, data); + } + + if (config === 'toggleSubmenu') { + data[config](); + } + }); + }; + + return Dropdown; + }(); + /** + * Data API + * ==================================================== + */ + + + $(Selector.DROPDOWN_MENU + ' ' + Selector.DROPDOWN_TOGGLE).on("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + + Dropdown._jQueryInterface.call($(this), 'toggleSubmenu'); + }); // $(Selector.SIDEBAR + ' a').on('focusin', () => { + // $(Selector.MAIN_SIDEBAR).addClass(ClassName.SIDEBAR_FOCUSED); + // }) + // $(Selector.SIDEBAR + ' a').on('focusout', () => { + // $(Selector.MAIN_SIDEBAR).removeClass(ClassName.SIDEBAR_FOCUSED); + // }) + + /** + * jQuery API + * ==================================================== + */ + + $.fn[NAME] = Dropdown._jQueryInterface; + $.fn[NAME].Constructor = Dropdown; + + $.fn[NAME].noConflict = function () { + $.fn[NAME] = JQUERY_NO_CONFLICT; + return Dropdown._jQueryInterface; + }; + + return Dropdown; + }(jQuery); + + /** + * -------------------------------------------- + * AdminLTE Toasts.js + * License MIT + * -------------------------------------------- + */ + var Toasts = function ($) { + /** + * Constants + * ==================================================== + */ + var NAME = 'Toasts'; + var DATA_KEY = 'lte.toasts'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $.fn[NAME]; + var Event = { + INIT: "init" + EVENT_KEY, + CREATED: "created" + EVENT_KEY, + REMOVED: "removed" + EVENT_KEY + }; + var Selector = { + BODY: 'toast-body', + CONTAINER_TOP_RIGHT: '#toastsContainerTopRight', + CONTAINER_TOP_LEFT: '#toastsContainerTopLeft', + CONTAINER_BOTTOM_RIGHT: '#toastsContainerBottomRight', + CONTAINER_BOTTOM_LEFT: '#toastsContainerBottomLeft' + }; + var ClassName = { + TOP_RIGHT: 'toasts-top-right', + TOP_LEFT: 'toasts-top-left', + BOTTOM_RIGHT: 'toasts-bottom-right', + BOTTOM_LEFT: 'toasts-bottom-left', + FADE: 'fade' + }; + var Position = { + TOP_RIGHT: 'topRight', + TOP_LEFT: 'topLeft', + BOTTOM_RIGHT: 'bottomRight', + BOTTOM_LEFT: 'bottomLeft' + }; + var Default = { + position: Position.TOP_RIGHT, + fixed: true, + autohide: false, + autoremove: true, + delay: 1000, + fade: true, + icon: null, + image: null, + imageAlt: null, + imageHeight: '25px', + title: null, + subtitle: null, + close: true, + body: null, + class: null + }; + /** + * Class Definition + * ==================================================== + */ + + var Toasts = + /*#__PURE__*/ + function () { + function Toasts(element, config) { + this._config = config; + + this._prepareContainer(); + + var initEvent = $.Event(Event.INIT); + $('body').trigger(initEvent); + } // Public + + + var _proto = Toasts.prototype; + + _proto.create = function create() { + var toast = $(' + +
Messages sent
+ + + + + + + + + + <%= for success <- @message_success do %> + + <%= if success.publisher.type == "pubsub" do %> + + <% else %> + + <% end %> + + + + <% end %> + +
TypeDestinationSent at
PubSubHttp<%= success.publisher.endpoint %><%= success.inserted_at %>
diff --git a/lib/postoffice_web/templates/page/index.html.eex b/lib/postoffice_web/templates/page/index.html.eex new file mode 100644 index 00000000..73072b4f --- /dev/null +++ b/lib/postoffice_web/templates/page/index.html.eex @@ -0,0 +1,7 @@ +
+ +
+ +
+ +
diff --git a/lib/postoffice_web/templates/publisher/edit.html.eex b/lib/postoffice_web/templates/publisher/edit.html.eex new file mode 100644 index 00000000..c3cc15eb --- /dev/null +++ b/lib/postoffice_web/templates/publisher/edit.html.eex @@ -0,0 +1,27 @@ +

Edit Publisher

+ +<%= form_for @changeset, Routes.publisher_path(@conn, :update, @publisher), fn f -> %> +
+ + <%= select f, :topic_id, @topics, disabled: true %> +
+ +
+ <%= label f, :endpoint, class: "control-label" %> + <%= textarea f, :endpoint, class: "form-control", rows: 5 %> +
+ +
+ + <%= checkbox f, :active %> +
+ +
+ <%= label f, :type, class: "control-label" %> + <%= select f, :type, Postoffice.Messaging.Publisher.types, rows: 5 %> +
+ +
+ <%= submit "Submit", class: "btn btn-primary" %> +
+<% end %> diff --git a/lib/postoffice_web/templates/publisher/index.html.eex b/lib/postoffice_web/templates/publisher/index.html.eex new file mode 100644 index 00000000..6df0e15e --- /dev/null +++ b/lib/postoffice_web/templates/publisher/index.html.eex @@ -0,0 +1,27 @@ +
+ +
+ + + + + + + + + + + + <%= for publisher <- @publishers do %> + + + + + + + + <% end %> + + \ No newline at end of file diff --git a/lib/postoffice_web/templates/publisher/new.html.eex b/lib/postoffice_web/templates/publisher/new.html.eex new file mode 100644 index 00000000..a5c7990a --- /dev/null +++ b/lib/postoffice_web/templates/publisher/new.html.eex @@ -0,0 +1,34 @@ +
+

Add new Publisher

+ +<%= form_for @changeset, Routes.publisher_path(@conn, :create), fn f -> %> +
+ + <%= select f, :topic_id, @topics %> +
+ +
+ <%= label f, :endpoint, class: "control-label" %> + <%= textarea f, :endpoint, class: "form-control", rows: 5 %> +
+ +
+ + <%= checkbox f, :active, value: true %> +
+ +
+ <%= label f, :type, class: "control-label"%> + <%= select f, :type, Postoffice.Messaging.Publisher.types %> +
+ +
+ + <%= checkbox f, :from_now %> +
+ +
+ <%= submit "Submit", class: "btn btn-primary" %> +
+<% end %> +
\ No newline at end of file diff --git a/lib/postoffice_web/views/api/health_view.ex b/lib/postoffice_web/views/api/health_view.ex new file mode 100644 index 00000000..fd477eb0 --- /dev/null +++ b/lib/postoffice_web/views/api/health_view.ex @@ -0,0 +1,9 @@ +defmodule PostofficeWeb.Api.HealthView do + use PostofficeWeb, :view + + def render("index.json", %{health_status: health_status}) do + %{ + status: health_status + } + end +end diff --git a/lib/postoffice_web/views/api/message_view.ex b/lib/postoffice_web/views/api/message_view.ex new file mode 100644 index 00000000..35645893 --- /dev/null +++ b/lib/postoffice_web/views/api/message_view.ex @@ -0,0 +1,18 @@ +defmodule PostofficeWeb.Api.MessageView do + use PostofficeWeb, :view + alias PostofficeWeb.Api.MessageView + + def render("index.json", %{messages: messages}) do + %{data: render_many(messages, MessageView, "message.json")} + end + + def render("show.json", %{message: message}) do + %{data: render_one(message, MessageView, "message.json")} + end + + def render("message.json", %{message: message}) do + %{ + public_id: message.public_id + } + end +end diff --git a/lib/postoffice_web/views/api/topic_view.ex b/lib/postoffice_web/views/api/topic_view.ex new file mode 100644 index 00000000..62519928 --- /dev/null +++ b/lib/postoffice_web/views/api/topic_view.ex @@ -0,0 +1,25 @@ +defmodule PostofficeWeb.Api.TopicView do + use PostofficeWeb, :view + alias PostofficeWeb.Api.TopicView + + def render("show.json", %{topic: topic}) do + %{data: render_one(topic, TopicView, "topic.json")} + end + + def render("show.json", %{changeset: changeset}) do + %{data: render_one(changeset, TopicView, "error.json")} + end + + def render("topic.json", %{topic: topic}) do + %{ + id: topic.id, + name: topic.name + } + end + + def render("error.json", %{topic: topic_changeset}) do + %{ + errors: Ecto.Changeset.traverse_errors(topic_changeset, &translate_error/1) + } + end +end diff --git a/lib/postoffice_web/views/changeset_view.ex b/lib/postoffice_web/views/changeset_view.ex new file mode 100644 index 00000000..0815bb89 --- /dev/null +++ b/lib/postoffice_web/views/changeset_view.ex @@ -0,0 +1,19 @@ +defmodule PostofficeWeb.ChangesetView do + use PostofficeWeb, :view + + @doc """ + Traverses and translates changeset errors. + + See `Ecto.Changeset.traverse_errors/2` and + `PostofficeWeb.ErrorHelpers.translate_error/1` for more details. + """ + def translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, &translate_error/1) + end + + def render("error.json", %{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: translate_errors(changeset)} + end +end diff --git a/lib/postoffice_web/views/error_helpers.ex b/lib/postoffice_web/views/error_helpers.ex new file mode 100644 index 00000000..e7fc6db3 --- /dev/null +++ b/lib/postoffice_web/views/error_helpers.ex @@ -0,0 +1,33 @@ +defmodule PostofficeWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate "is invalid" in the "errors" domain + # dgettext("errors", "is invalid") + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + # This requires us to call the Gettext module passing our gettext + # backend as first argument. + # + # Note we use the "errors" domain, which means translations + # should be written to the errors.po file. The :count option is + # set by Ecto and indicates we should also apply plural rules. + if count = opts[:count] do + Gettext.dngettext(PostofficeWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(PostofficeWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/lib/postoffice_web/views/error_view.ex b/lib/postoffice_web/views/error_view.ex new file mode 100644 index 00000000..a38bdbb5 --- /dev/null +++ b/lib/postoffice_web/views/error_view.ex @@ -0,0 +1,16 @@ +defmodule PostofficeWeb.ErrorView do + use PostofficeWeb, :view + + # If you want to customize a particular status code + # for a certain format, you may uncomment below. + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def template_not_found(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/postoffice_web/views/index_view.ex b/lib/postoffice_web/views/index_view.ex new file mode 100644 index 00000000..2a0e58a9 --- /dev/null +++ b/lib/postoffice_web/views/index_view.ex @@ -0,0 +1,3 @@ +defmodule PostofficeWeb.IndexView do + use PostofficeWeb, :view +end diff --git a/lib/postoffice_web/views/layout_view.ex b/lib/postoffice_web/views/layout_view.ex new file mode 100644 index 00000000..1e7deed7 --- /dev/null +++ b/lib/postoffice_web/views/layout_view.ex @@ -0,0 +1,3 @@ +defmodule PostofficeWeb.LayoutView do + use PostofficeWeb, :view +end diff --git a/lib/postoffice_web/views/message_view.ex b/lib/postoffice_web/views/message_view.ex new file mode 100644 index 00000000..6e74d611 --- /dev/null +++ b/lib/postoffice_web/views/message_view.ex @@ -0,0 +1,3 @@ +defmodule PostofficeWeb.MessageView do + use PostofficeWeb, :view +end diff --git a/lib/postoffice_web/views/page_view.ex b/lib/postoffice_web/views/page_view.ex new file mode 100644 index 00000000..da0ba078 --- /dev/null +++ b/lib/postoffice_web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule PostofficeWeb.PageView do + use PostofficeWeb, :view +end diff --git a/lib/postoffice_web/views/publisher_view.ex b/lib/postoffice_web/views/publisher_view.ex new file mode 100644 index 00000000..7573ec98 --- /dev/null +++ b/lib/postoffice_web/views/publisher_view.ex @@ -0,0 +1,3 @@ +defmodule PostofficeWeb.PublisherView do + use PostofficeWeb, :view +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 00000000..31d7996b --- /dev/null +++ b/mix.exs @@ -0,0 +1,78 @@ +defmodule Postoffice.MixProject do + use Mix.Project + + def project do + [ + app: :postoffice, + version: "0.2.0", + elixir: "~> 1.9", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix, :gettext] ++ Mix.compilers(), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + include_erts: true, + releases: [ + postoffice: [ + config_providers: [{ConfigTuples.Provider, ""}] + ] + ] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Postoffice.Application, []}, + extra_applications: [:logger, :runtime_tools, :httpoison, :logger_json] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.4.7"}, + {:phoenix_pubsub, "~> 1.1"}, + {:phoenix_ecto, "~> 4.0"}, + {:ecto_sql, "~> 3.0"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 2.13.3"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:gettext, "~> 0.11"}, + {:jason, "~> 1.0"}, + {:plug_cowboy, "~> 2.0"}, + {:bcrypt_elixir, "~> 2.0"}, + {:google_api_pub_sub, "~> 0.14.0"}, + {:goth, "~> 1.1.0"}, + {:httpoison, "~> 1.6"}, + {:mox, "~> 0.5", only: :test}, + {:gen_stage, "~> 0.14"}, + {:logger_json, "~> 3.0"}, + {:config_tuples, "~> 0.4"}, + {:libcluster, "~> 3.1"}, + {:swarm, "~> 3.0"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to create, migrate and run the seeds file at once: + # + # $ mix ecto.setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 00000000..e0b7ea5b --- /dev/null +++ b/mix.lock @@ -0,0 +1,52 @@ +%{ + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.0.3", "64e0792d5b5064391927bf3b8e436994cafd18ca2d2b76dea5c76e0adcf66b7c", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "comeonin": {:hex, :comeonin, "5.1.3", "4c9880ed348cc0330c74086b4383ffb0b5a599aa603416497b7374c168cae340", [:mix], [], "hexpm"}, + "config_tuples": {:hex, :config_tuples, "0.4.1", "af272da2ec1406a3bbbd98b66cb238e9fed6b928eba386a6e55026fac8689b44", [:mix], [{:distillery, "~> 2.1", [hex: :distillery, repo: "hexpm", optional: true]}], "hexpm"}, + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, + "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"}, + "db_connection": {:hex, :db_connection, "2.2.0", "e923e88887cd60f9891fd324ac5e0290954511d090553c415fbf54be4c57ee63", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, + "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, + "ecto": {:hex, :ecto, "3.3.0", "9193e261d25c1814324d0b3304fccbadab840b286d270c3b75dfd28c30a3ae15", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "ecto_sql": {:hex, :ecto_sql, "3.3.2", "92804e0de69bb63e621273c3492252cb08a29475c05d40eeb6f41ad2d483cfd3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, + "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"}, + "file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"}, + "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm"}, + "google_api_pub_sub": {:hex, :google_api_pub_sub, "0.14.0", "6babac64ac8ee2e705a180ad2c1ac15a58e68cbeee506aaa2e96f55b6aaa06da", [:mix], [{:google_gax, "~> 0.2", [hex: :google_gax, repo: "hexpm", optional: false]}], "hexpm"}, + "google_gax": {:hex, :google_gax, "0.3.1", "c8841dfbbaf26f8aaeac0ae86246d7094e1a276db198b974cd14c30691d765b4", [:mix], [{:poison, ">= 3.0.0 and < 5.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"}, + "goth": {:hex, :goth, "1.1.0", "85977656822e54217bc0472666f1ce15dc3921495ef5f4f0774ef15503bae207", [:mix], [{:httpoison, "~> 0.11 or ~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}], "hexpm"}, + "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, + "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, + "libcluster": {:hex, :libcluster, "3.1.1", "cbab97b96141f47f2fe5563183c444bbce9282b3991ef054d69b8805546f0122", [:mix], [{:jason, "~> 1.1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, + "logger_json": {:hex, :logger_json, "3.2.0", "4323af6a068e8794c697270541a7af28b8f1ca39a7e1475711b0abd34fa8a83f", [:mix], [{:ecto, "~> 2.1 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, + "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "phoenix": {:hex, :phoenix, "1.4.11", "d112c862f6959f98e6e915c3b76c7a87ca3efd075850c8daa7c3c7a609014b0d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, + "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"}, + "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, + "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, + "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm"}, + "tesla": {:hex, :tesla, "1.3.0", "f35d72f029e608f9cdc6f6d6fcc7c66cf6d6512a70cfef9206b21b8bd0203a30", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 0.4", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 00000000..a589998c --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,97 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 00000000..39a220be --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,95 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 00000000..49f9151e --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20191001220749_add_topic_schema.exs b/priv/repo/migrations/20191001220749_add_topic_schema.exs new file mode 100644 index 00000000..0609fd28 --- /dev/null +++ b/priv/repo/migrations/20191001220749_add_topic_schema.exs @@ -0,0 +1,11 @@ +defmodule Postoffice.Repo.Migrations.AddTopicSchema do + use Ecto.Migration + + def change do + create table(:topics) do + add :name, :string + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20191002095653_create_messages.exs b/priv/repo/migrations/20191002095653_create_messages.exs new file mode 100644 index 00000000..61c28bd6 --- /dev/null +++ b/priv/repo/migrations/20191002095653_create_messages.exs @@ -0,0 +1,15 @@ +defmodule Postoffice.Repo.Migrations.CreateMessages do + use Ecto.Migration + + def change do + create table(:messages) do + add :public_id, :uuid + add :payload, :jsonb + # add :topic, :string + add :topic_id, references(:topics), null: false + add :attributes, :map + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20191010151745_create_publishers.exs b/priv/repo/migrations/20191010151745_create_publishers.exs new file mode 100644 index 00000000..8bcbd899 --- /dev/null +++ b/priv/repo/migrations/20191010151745_create_publishers.exs @@ -0,0 +1,15 @@ +defmodule Postoffice.Repo.Migrations.CreatePublishers do + use Ecto.Migration + + def change do + create table(:publishers) do + add :endpoint, :string + add :active, :boolean, default: false, null: false + add :rate_limit, :integer + add :type, :string + add :topic_id, references(:topics), null: false + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20191013173545_create_publisher_successs.exs b/priv/repo/migrations/20191013173545_create_publisher_successs.exs new file mode 100644 index 00000000..fa29f0b6 --- /dev/null +++ b/priv/repo/migrations/20191013173545_create_publisher_successs.exs @@ -0,0 +1,12 @@ +defmodule Postoffice.Repo.Migrations.CreatePublisherSuccesss do + use Ecto.Migration + + def change do + create table(:publisher_success) do + add :message_id, references(:messages), null: false + add :publisher_id, references(:publishers), null: false + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20191114231359_create_publisher_failures.exs b/priv/repo/migrations/20191114231359_create_publisher_failures.exs new file mode 100644 index 00000000..7274321d --- /dev/null +++ b/priv/repo/migrations/20191114231359_create_publisher_failures.exs @@ -0,0 +1,12 @@ +defmodule Postoffice.Repo.Migrations.CreatePublisherFaillures do + use Ecto.Migration + + def change do + create table(:publisher_failures) do + add :message_id, references(:messages), null: false + add :publisher_id, references(:publishers), null: false + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20191118095313_add_initial_message_to_publishers.exs b/priv/repo/migrations/20191118095313_add_initial_message_to_publishers.exs new file mode 100644 index 00000000..07948fdd --- /dev/null +++ b/priv/repo/migrations/20191118095313_add_initial_message_to_publishers.exs @@ -0,0 +1,9 @@ +defmodule Postoffice.Repo.Migrations.AddInitialMessageToPublishers do + use Ecto.Migration + + def change do + alter table(:publishers) do + add :initial_message, :integer + end + end +end diff --git a/priv/repo/migrations/20191120092108_remove_rate_limit.exs b/priv/repo/migrations/20191120092108_remove_rate_limit.exs new file mode 100644 index 00000000..7c19d744 --- /dev/null +++ b/priv/repo/migrations/20191120092108_remove_rate_limit.exs @@ -0,0 +1,9 @@ +defmodule Postoffice.Repo.Migrations.RemoveRateLimit do + use Ecto.Migration + + def change do + alter table(:publishers) do + remove :rate_limit + end + end +end diff --git a/priv/repo/migrations/20191122154310_add_processed_to_message_failure.exs b/priv/repo/migrations/20191122154310_add_processed_to_message_failure.exs new file mode 100644 index 00000000..69ef763a --- /dev/null +++ b/priv/repo/migrations/20191122154310_add_processed_to_message_failure.exs @@ -0,0 +1,9 @@ +defmodule Postoffice.Repo.Migrations.AddProcessedToMessageFailure do + use Ecto.Migration + + def change do + alter table(:publisher_failures) do + add :processed, :boolean + end + end +end diff --git a/priv/repo/migrations/20191126114251_add_index_on_messages_uuid.exs b/priv/repo/migrations/20191126114251_add_index_on_messages_uuid.exs new file mode 100644 index 00000000..c13bfb35 --- /dev/null +++ b/priv/repo/migrations/20191126114251_add_index_on_messages_uuid.exs @@ -0,0 +1,7 @@ +defmodule Postoffice.Repo.Migrations.AddIndexOnMessagesUuid do + use Ecto.Migration + + def change do + create index("messages", [:public_id], unique: true, name: "unique_uuids") + end +end diff --git a/priv/repo/migrations/20191126120011_add_some_indexes.exs b/priv/repo/migrations/20191126120011_add_some_indexes.exs new file mode 100644 index 00000000..62962528 --- /dev/null +++ b/priv/repo/migrations/20191126120011_add_some_indexes.exs @@ -0,0 +1,11 @@ +defmodule Postoffice.Repo.Migrations.AddSomeIndexes do + use Ecto.Migration + + def change do + create index("messages", [:topic_id], name: "external_topic_id") + + create index("publisher_success", [:message_id, :publisher_id], + name: "message_and_publishers_search" + ) + end +end diff --git a/priv/repo/migrations/20191216095601_add_publisher_index_on_publisher_success.exs b/priv/repo/migrations/20191216095601_add_publisher_index_on_publisher_success.exs new file mode 100644 index 00000000..fd8f7725 --- /dev/null +++ b/priv/repo/migrations/20191216095601_add_publisher_index_on_publisher_success.exs @@ -0,0 +1,7 @@ +defmodule Postoffice.Repo.Migrations.AddPublisherIndexOnPublisherSuccess do + use Ecto.Migration + + def change do + create index("publisher_success", [:publisher_id], name: "publisher_id") + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 00000000..5e99fec0 --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Postoffice.Repo.insert!(%Postoffice.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/secrets/dummy-credentials.json b/secrets/dummy-credentials.json new file mode 100644 index 00000000..9cfb1fed --- /dev/null +++ b/secrets/dummy-credentials.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "fake", + "private_key_id": "e787f869fab38c2218d7f33f0d1be78f79e85b0b", + "private_key": "-----BEGIN PRIVATE KEY-----\nfake_private_key\n-----END PRIVATE KEY-----\n", + "client_email": "pubsub-editor@fake.iam.gserviceaccount.com", + "client_id": "100821664174646783379", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/pubsub-editor%40fake.iam.gserviceaccount.com" +} \ No newline at end of file diff --git a/test/postoffice/dispatch_test.exs b/test/postoffice/dispatch_test.exs new file mode 100644 index 00000000..770350f3 --- /dev/null +++ b/test/postoffice/dispatch_test.exs @@ -0,0 +1,51 @@ +defmodule Postoffice.DispatchTest do + use ExUnit.Case, async: true + + alias Postoffice.Dispatch + + @two_elements_attrs [ + %{name: "element1"}, + %{name: "element2"} + ] + + def two_elements_queue_fixture() do + queue = :queue.new() + + Enum.reduce(@two_elements_attrs, queue, fn element, acc -> + :queue.in(element, acc) + end) + end + + describe "Dispatch module" do + test "Demanding without events on an empty queue returns no events" do + queue = :queue.new() + assert Dispatch.dispatch_events(queue, 1, []) == {[], {{[], []}, 1}} + end + + test "Demanding more than queue size when queue is empty returns the passed demand" do + queue = :queue.new() + assert Dispatch.dispatch_events(queue, 3, []) == {[], {{[], []}, 3}} + end + + test "Queue with two elements, having no elements at that time, returns one element, the updated queue plus remaining demand" do + queue = two_elements_queue_fixture() + + assert Dispatch.dispatch_events(queue, 1, []) == + {[%{name: "element1"}], {{[], [%{name: "element2"}]}, 0}} + end + + test "Demanding more than queue size left pending demand" do + queue = two_elements_queue_fixture() + + assert Dispatch.dispatch_events(queue, 3, []) == + {[%{name: "element1"}, %{name: "element2"}], {{[], []}, 1}} + end + + test "Dispatching from queue without demand returns the same queue and no pending demand" do + queue = two_elements_queue_fixture() + + assert Dispatch.dispatch_events(queue, 0, []) == + {[], {{[%{name: "element2"}], [%{name: "element1"}]}, 0}} + end + end +end diff --git a/test/postoffice/handlers/http_test.exs b/test/postoffice/handlers/http_test.exs new file mode 100644 index 00000000..43a213be --- /dev/null +++ b/test/postoffice/handlers/http_test.exs @@ -0,0 +1,103 @@ +defmodule Postoffice.Handlers.HttpTest do + use ExUnit.Case + + import Mox + + alias Postoffice.Adapters.HttpMock + alias Postoffice.Handlers.Http + alias Postoffice.Messaging + alias Postoffice.Messaging.Message + + @valid_message_attrs %{ + attributes: %{}, + payload: %{}, + public_id: "7488a646-e31f-11e4-aace-600308960662" + } + @valid_publisher_attrs %{ + active: true, + endpoint: "http://fake.endpoint", + topic: "test", + type: "http", + initial_message: 0 + } + @valid_topic_attrs %{ + name: "test" + } + + setup [:set_mox_from_context, :verify_on_exit!] + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Postoffice.Repo) + end + + test "no message_success when target endpoint not found" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + message = %Message{ + attributes: %{}, + payload: %{}, + public_id: "7488a646-e31f-11e4-aace-600308960662", + topic_id: topic.id + } + + expect(HttpMock, :publish, fn "http://fake.endpoint", ^message -> + {:ok, 404} + end) + + Http.run(publisher.endpoint, publisher.id, message) + assert [] = Messaging.list_publisher_success(publisher.id) + end + + test "message success is created for publisher if message is successfully delivered" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + {:ok, message} = Messaging.create_message(topic, @valid_message_attrs) + + expect(HttpMock, :publish, fn "http://fake.endpoint", ^message -> + {:ok, message} + end) + + Http.run(publisher.endpoint, publisher.id, message) + assert [message] = Messaging.list_publisher_success(publisher.id) + end + + test "message_failure is created for publisher if any error happens" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + {:ok, message} = Messaging.create_message(topic, @valid_message_attrs) + + expect(HttpMock, :publish, fn "http://fake.endpoint", ^message -> + {:error, %HTTPoison.Error{reason: "test"}} + end) + + Http.run(publisher.endpoint, publisher.id, message) + message_failure = List.first(Messaging.list_publisher_failures(publisher.id)) + assert message_failure.message_id == message.id + end + + test "message_failure is created for publisher if response is :ok but response_code != 200" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + {:ok, message} = Messaging.create_message(topic, @valid_message_attrs) + + expect(HttpMock, :publish, fn "http://fake.endpoint", ^message -> + {:error, %HTTPoison.Response{status_code: 201}} + end) + + Http.run(publisher.endpoint, publisher.id, message) + message_failure = List.first(Messaging.list_publisher_failures(publisher.id)) + assert message_failure.message_id == message.id + end +end diff --git a/test/postoffice/handlers/pubsub_test.exs b/test/postoffice/handlers/pubsub_test.exs new file mode 100644 index 00000000..87f8edcc --- /dev/null +++ b/test/postoffice/handlers/pubsub_test.exs @@ -0,0 +1,85 @@ +defmodule Postoffice.Handlers.PubsubTest do + use ExUnit.Case + + import Mox + + alias Postoffice.Adapters.PubsubMock + alias Postoffice.Handlers.Pubsub + alias Postoffice.Messaging + alias Postoffice.Messaging.Message + + @valid_message_attrs %{ + attributes: %{}, + payload: %{}, + public_id: "7488a646-e31f-11e4-aace-600308960662" + } + @valid_publisher_attrs %{ + active: true, + topic: "test", + endpoint: "test-publisher", + type: "pubsub", + initial_message: 0 + } + @valid_topic_attrs %{ + name: "test" + } + setup [:set_mox_from_context, :verify_on_exit!] + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Postoffice.Repo) + end + + test "no message_success when some error raised from pubsub" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + message = %Message{ + attributes: %{}, + payload: %{}, + public_id: "7488a646-e31f-11e4-aace-600308960662", + topic_id: topic.id + } + + expect(PubsubMock, :publish, fn "test-publisher", ^message -> + {:error, "test error"} + end) + + Pubsub.run(publisher.endpoint, publisher.id, message) + assert [] = Messaging.list_publisher_success(publisher.id) + end + + test "message success is created for publisher if message is successfully delivered" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + {:ok, message} = Messaging.create_message(topic, @valid_message_attrs) + + expect(PubsubMock, :publish, fn "test-publisher", ^message -> + {:ok, message} + end) + + Pubsub.run(publisher.endpoint, publisher.id, message) + assert [message] = Messaging.list_publisher_success(publisher.id) + end + + test "message failure is created for publisher if we're not able to send to pubsub" do + {:ok, topic} = Messaging.create_topic(@valid_topic_attrs) + + {:ok, publisher} = + Messaging.create_publisher(Map.put(@valid_publisher_attrs, :topic_id, topic.id)) + + {:ok, message} = Messaging.create_message(topic, @valid_message_attrs) + + expect(PubsubMock, :publish, fn "test-publisher", ^message -> + {:error, "Not able to deliver"} + end) + + Pubsub.run(publisher.endpoint, publisher.id, message) + publisher_failure = List.first(Messaging.list_publisher_failures(publisher.id)) + assert publisher_failure.message_id == message.id + end +end diff --git a/test/postoffice/messaging_test.exs b/test/postoffice/messaging_test.exs new file mode 100644 index 00000000..1a291b73 --- /dev/null +++ b/test/postoffice/messaging_test.exs @@ -0,0 +1,234 @@ +defmodule Postoffice.MessagingTest do + use Postoffice.DataCase + + alias Postoffice.Messaging + + describe "messages" do + alias Postoffice.Messaging.Message + + @topic_attrs %{ + name: "test" + } + + @second_topic_attrs %{ + name: "test2" + } + + @publisher_attrs %{ + active: true, + endpoint: "http://fake.endpoint", + initial_message: 0, + type: "http" + } + + @second_publisher_attrs %{ + active: true, + endpoint: "http://fake.endpoint2", + initial_message: 0, + type: "http" + } + + @valid_attrs %{ + attributes: %{}, + payload: %{}, + public_id: "7488a646-e31f-11e4-aace-600308960662" + } + @update_attrs %{ + attributes: %{}, + payload: %{}, + public_id: "7488a646-e31f-11e4-aace-600308960668" + } + @invalid_attrs %{attributes: nil, payload: nil, public_id: nil, topic: nil} + + def message_fixture(topic, attrs \\ @valid_attrs) do + {:ok, message} = Messaging.create_message(topic, attrs) + + message + end + + def topic_fixture(attrs \\ @topic_attrs) do + {:ok, topic} = Messaging.create_topic(attrs) + topic + end + + def publisher_fixture(topic, attrs \\ @publisher_attrs) do + {:ok, publisher} = Messaging.create_publisher(Map.put(attrs, :topic_id, topic.id)) + publisher + end + + def publisher_success_fixture(message, publisher) do + {:ok, _publisher_success} = + Messaging.create_publisher_success(%{message_id: message.id, publisher_id: publisher.id}) + end + + test "list_messages/0 returns all messages" do + topic = topic_fixture() + message = message_fixture(topic) + + assert Messaging.list_messages() == [message] + end + + test "list_messages/0 returns empty list in case no message exists" do + assert Messaging.list_messages() == [] + end + + test "get_message!/1 returns the message with given id" do + topic = topic_fixture() + message = message_fixture(topic) + message_found = Messaging.get_message!(message.id) + + assert message.id == message_found.id + end + + test "create_message/1 with valid data creates a message" do + topic = topic_fixture() + + assert {:ok, %Message{} = message} = Messaging.create_message(topic, @valid_attrs) + assert message.attributes == %{} + assert message.payload == %{} + assert message.public_id == "7488a646-e31f-11e4-aace-600308960662" + assert message.topic_id == topic.id + end + + test "create_message/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + Messaging.create_message(topic_fixture(), @invalid_attrs) + end + + test "update_message/2 with valid data updates the message" do + topic = topic_fixture() + message = message_fixture(topic) + + assert {:ok, %Message{} = message} = Messaging.update_message(message, @update_attrs) + assert message.attributes == %{} + assert message.payload == %{} + assert message.public_id == "7488a646-e31f-11e4-aace-600308960668" + end + + test "update_message/2 with invalid data returns error changeset" do + topic = topic_fixture() + message = message_fixture(topic) + + assert {:error, %Ecto.Changeset{}} = Messaging.update_message(message, @invalid_attrs) + end + + test "delete_message/1 deletes the message" do + topic = topic_fixture() + message = message_fixture(topic) + + assert {:ok, %Message{}} = Messaging.delete_message(message) + assert Messaging.get_message!(message.id) == nil + end + + test "change_message/1 returns a message changeset" do + topic = topic_fixture() + message = message_fixture(topic) + + assert %Ecto.Changeset{} = Messaging.change_message(message) + end + + test "list_topics/0 returns all topics" do + topic = topic_fixture() + + assert Messaging.list_topics() == [topic] + end + + test "list_topics/0 returns empty list in case no topic exists" do + assert Messaging.list_topics() == [] + end + + test "list_publishers/0 returns empty list if no publisher exists" do + assert Messaging.list_publishers() == [] + end + + test "list_publishers/0 returns all existing publishers" do + publisher = publisher_fixture(topic_fixture()) + listed_publisher = List.first(Messaging.list_publishers()) + + assert publisher.id == listed_publisher.id + assert publisher.endpoint == listed_publisher.endpoint + assert publisher.active == listed_publisher.active + assert publisher.type == listed_publisher.type + end + + test "get_message_by_uuid returns message is found" do + topic = topic_fixture() + message = message_fixture(topic) + searched_message = Messaging.get_message_by_uuid(message.public_id) + + assert message.id == searched_message.id + end + + test "message_already_processed returns false if it hasnt been processed for a publisher" do + topic = topic_fixture() + message = message_fixture(topic) + topic = topic_fixture() + publisher = publisher_fixture(topic) + + assert Messaging.message_already_processed(message.id, publisher.id) == false + end + + test "message_already_processed returns true if it has been processed for a publisher" do + topic = topic_fixture() + message = message_fixture(topic) + topic = topic_fixture() + publisher = publisher_fixture(topic) + publisher_success_fixture(message, publisher) + + assert Messaging.message_already_processed(message.id, publisher.id) + end + + test "list_pending_messages_for_publisher/2 returns empty if no pending messages for a given publisher" do + topic = topic_fixture() + publisher = publisher_fixture(topic) + + assert Messaging.list_pending_messages_for_publisher(publisher.id, topic.id) == [] + end + + test "list_pending_messages_for_publisher/2 returns empty if no pending messages for a given publisher but we have pending messages for other publisher" do + topic = topic_fixture() + publisher = publisher_fixture(topic) + + second_topic = topic_fixture(@second_topic_attrs) + _second_publisher = publisher_fixture(second_topic, @second_publisher_attrs) + _message = message_fixture(second_topic) + + assert Messaging.list_pending_messages_for_publisher(publisher.id, topic.id) == [] + end + + test "list_pending_messages_for_publisher/2 returns messages for a given publisher and topic" do + topic = topic_fixture() + publisher = publisher_fixture(topic) + message = message_fixture(topic) + + pending_messages = Messaging.list_pending_messages_for_publisher(publisher.id, topic.id) + + assert Kernel.length(pending_messages) == 1 + pending_message = List.first(pending_messages) + assert pending_message.id == message.id + assert pending_message.topic_id == topic.id + end + + test "list_pending_messages_for_publisher/2 returns messages for a given publisher and topic when there are pending messages for other topics" do + topic = topic_fixture() + publisher = publisher_fixture(topic) + message = message_fixture(topic) + + second_topic = topic_fixture(@second_topic_attrs) + publisher_fixture(second_topic, @second_publisher_attrs) + + _second_message = + message_fixture( + second_topic, + Map.put(@valid_attrs, :public_id, "2d823585-68f8-49cd-89c0-07c1572572c1") + ) + + pending_messages = Messaging.list_pending_messages_for_publisher(publisher.id, topic.id) + + assert Kernel.length(pending_messages) == 1 + pending_message = List.first(pending_messages) + assert pending_message.id == message.id + assert pending_message.topic_id == topic.id + end + end +end diff --git a/test/postoffice/postoffice_test.exs b/test/postoffice/postoffice_test.exs new file mode 100644 index 00000000..bce20f85 --- /dev/null +++ b/test/postoffice/postoffice_test.exs @@ -0,0 +1,11 @@ +defmodule Postoffice.PostofficeTest do + use Postoffice.DataCase + + alias Postoffice + + describe "PostofficeWeb external api" do + test "Returns nil if tried to find message by invalid UUID" do + assert Postoffice.find_message_by_uuid(123) == nil + end + end +end diff --git a/test/postoffice_web/controllers/api/message_controller_test.exs b/test/postoffice_web/controllers/api/message_controller_test.exs new file mode 100644 index 00000000..feb31da3 --- /dev/null +++ b/test/postoffice_web/controllers/api/message_controller_test.exs @@ -0,0 +1,35 @@ +defmodule PostofficeWeb.Api.MessageControllerTest do + use PostofficeWeb.ConnCase + + alias Postoffice.Messaging + + @create_attrs %{ + attributes: %{}, + payload: %{"key" => "test", "key_list" => [%{"letter" => "a"}, %{"letter" => "b"}]}, + topic: "test" + } + @invalid_attrs %{attributes: nil, payload: nil, topic: "test"} + + def fixture(:message) do + {:ok, topic} = Messaging.create_topic(%{name: "test", id: 1}) + {:ok, message} = Messaging.create_message(topic, @create_attrs) + message + end + + setup %{conn: conn} do + {:ok, _topic} = Messaging.create_topic(%{name: "test"}) + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "create message" do + test "renders message when data is valid", %{conn: conn} do + conn = post(conn, Routes.api_message_path(conn, :create), message: @create_attrs) + assert %{"public_id" => id} = json_response(conn, 201)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.api_message_path(conn, :create), message: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end +end diff --git a/test/postoffice_web/controllers/api/topic_controller_test.exs b/test/postoffice_web/controllers/api/topic_controller_test.exs new file mode 100644 index 00000000..160890d5 --- /dev/null +++ b/test/postoffice_web/controllers/api/topic_controller_test.exs @@ -0,0 +1,34 @@ +defmodule PostofficeWeb.Api.TopicControllerTest do + use PostofficeWeb.ConnCase + + alias Postoffice.Messaging + + @create_attrs %{ + name: "test" + } + @invalid_attrs %{invalid_key: "invalid"} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "create topic" do + test "renders created topic information when data is valid", %{conn: conn} do + conn = post(conn, Routes.api_topic_path(conn, :create), topic: @create_attrs) + created_topic = json_response(conn, 201)["data"] + assert created_topic["name"] == "test" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.api_topic_path(conn, :create), topic: @invalid_attrs) + assert json_response(conn, 400)["errors"] != %{} + end + + test "do not create topic in case it already exists", %{conn: conn} do + {:ok, existing_topic} = Messaging.create_topic(%{name: "test"}) + conn = post(conn, Routes.api_topic_path(conn, :create), topic: @create_attrs) + new_topic = json_response(conn, 201)["data"] + assert new_topic["id"] == existing_topic.id + end + end +end diff --git a/test/postoffice_web/views/error_view_test.exs b/test/postoffice_web/views/error_view_test.exs new file mode 100644 index 00000000..7a8bc85d --- /dev/null +++ b/test/postoffice_web/views/error_view_test.exs @@ -0,0 +1,15 @@ +defmodule PostofficeWeb.ErrorViewTest do + use PostofficeWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.json" do + assert render(PostofficeWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500.json" do + assert render(PostofficeWeb.ErrorView, "500.json", []) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 00000000..3f20b246 --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,37 @@ +defmodule PostofficeWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + use Phoenix.ChannelTest + + # The default endpoint for testing + @endpoint PostofficeWeb.Endpoint + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Postoffice.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Postoffice.Repo, {:shared, self()}) + end + + :ok + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 00000000..d9dd15f0 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule PostofficeWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + use Phoenix.ConnTest + alias PostofficeWeb.Router.Helpers, as: Routes + + # The default endpoint for testing + @endpoint PostofficeWeb.Endpoint + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Postoffice.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Postoffice.Repo, {:shared, self()}) + end + + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 00000000..672a4e24 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,53 @@ +defmodule Postoffice.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Postoffice.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Postoffice.DataCase + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Postoffice.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Postoffice.Repo, {:shared, self()}) + end + + :ok + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 00000000..7190543c --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,17 @@ +ExUnit.start(trace: true) +Ecto.Adapters.SQL.Sandbox.mode(Postoffice.Repo, :manual) + +Mox.defmock(Postoffice.Adapters.HttpMock, for: Postoffice.Adapters.Impl) +Mox.defmock(Postoffice.Adapters.PubsubMock, for: Postoffice.Adapters.Impl) + +Application.put_env( + :postoffice, + :http_consumer_impl, + Postoffice.Adapters.HttpMock +) + +Application.put_env( + :postoffice, + :pubsub_consumer_impl, + Postoffice.Adapters.PubsubMock +)
#TopicEndpointActiveType
<%= link "#{publisher.id}", to: Routes.publisher_path(@conn, :edit, publisher.id) %><%= publisher.topic.name %><%= publisher.endpoint %><%= publisher.active %><%= publisher.type %>