From 8b271a12ea2e26c339fc212e0278048e89772ff8 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 12:34:10 +0100 Subject: [PATCH 1/3] Add Rails support desk with serialized staff claims --- .github/workflows/support-desk.yml | 32 +++ applications/support-desk/.env.example | 12 + applications/support-desk/.gitignore | 8 + applications/support-desk/.ruby-version | 1 + applications/support-desk/Gemfile | 9 + applications/support-desk/Gemfile.lock | 259 ++++++++++++++++++ applications/support-desk/README.md | 214 +++++++++++++++ applications/support-desk/Rakefile | 2 + .../app/assets/stylesheets/application.css | 258 +++++++++++++++++ .../app/controllers/application_controller.rb | 40 +++ .../app/controllers/replies_controller.rb | 16 ++ .../app/controllers/sessions_controller.rb | 24 ++ .../app/controllers/tickets_controller.rb | 68 +++++ .../app/javascript/application.js | 1 + .../app/models/application_record.rb | 3 + applications/support-desk/app/models/reply.rb | 6 + .../support-desk/app/models/ticket.rb | 37 +++ applications/support-desk/app/models/user.rb | 8 + .../app/views/layouts/application.html.erb | 20 ++ .../app/views/sessions/new.html.erb | 11 + .../app/views/tickets/index.html.erb | 19 ++ .../app/views/tickets/new.html.erb | 9 + .../app/views/tickets/show.html.erb | 35 +++ applications/support-desk/bin/rails | 4 + applications/support-desk/config.ru | 3 + .../support-desk/config/application.rb | 24 ++ applications/support-desk/config/boot.rb | 2 + applications/support-desk/config/database.yml | 22 ++ .../support-desk/config/environment.rb | 2 + .../config/environments/development.rb | 10 + .../config/environments/production.rb | 11 + applications/support-desk/config/importmap.rb | 2 + applications/support-desk/config/puma.rb | 4 + applications/support-desk/config/routes.rb | 10 + .../20261002000000_create_support_desk.rb | 32 +++ applications/support-desk/db/seeds.rb | 20 ++ applications/support-desk/sql/bootstrap.sql | 10 + applications/support-desk/sql/cleanup.sql | 5 + applications/support-desk/sql/grants.sql | 6 + applications/support-desk/test/acceptance.rb | 224 +++++++++++++++ applications/support-desk/test/browser.py | 55 ++++ applications/support-desk/test/persistence.py | 40 +++ .../support-desk/test/requirements.txt | 2 + 43 files changed, 1580 insertions(+) create mode 100644 .github/workflows/support-desk.yml create mode 100644 applications/support-desk/.env.example create mode 100644 applications/support-desk/.gitignore create mode 100644 applications/support-desk/.ruby-version create mode 100644 applications/support-desk/Gemfile create mode 100644 applications/support-desk/Gemfile.lock create mode 100644 applications/support-desk/README.md create mode 100644 applications/support-desk/Rakefile create mode 100644 applications/support-desk/app/assets/stylesheets/application.css create mode 100644 applications/support-desk/app/controllers/application_controller.rb create mode 100644 applications/support-desk/app/controllers/replies_controller.rb create mode 100644 applications/support-desk/app/controllers/sessions_controller.rb create mode 100644 applications/support-desk/app/controllers/tickets_controller.rb create mode 100644 applications/support-desk/app/javascript/application.js create mode 100644 applications/support-desk/app/models/application_record.rb create mode 100644 applications/support-desk/app/models/reply.rb create mode 100644 applications/support-desk/app/models/ticket.rb create mode 100644 applications/support-desk/app/models/user.rb create mode 100644 applications/support-desk/app/views/layouts/application.html.erb create mode 100644 applications/support-desk/app/views/sessions/new.html.erb create mode 100644 applications/support-desk/app/views/tickets/index.html.erb create mode 100644 applications/support-desk/app/views/tickets/new.html.erb create mode 100644 applications/support-desk/app/views/tickets/show.html.erb create mode 100755 applications/support-desk/bin/rails create mode 100644 applications/support-desk/config.ru create mode 100644 applications/support-desk/config/application.rb create mode 100644 applications/support-desk/config/boot.rb create mode 100644 applications/support-desk/config/database.yml create mode 100644 applications/support-desk/config/environment.rb create mode 100644 applications/support-desk/config/environments/development.rb create mode 100644 applications/support-desk/config/environments/production.rb create mode 100644 applications/support-desk/config/importmap.rb create mode 100644 applications/support-desk/config/puma.rb create mode 100644 applications/support-desk/config/routes.rb create mode 100644 applications/support-desk/db/migrate/20261002000000_create_support_desk.rb create mode 100644 applications/support-desk/db/seeds.rb create mode 100644 applications/support-desk/sql/bootstrap.sql create mode 100644 applications/support-desk/sql/cleanup.sql create mode 100644 applications/support-desk/sql/grants.sql create mode 100644 applications/support-desk/test/acceptance.rb create mode 100644 applications/support-desk/test/browser.py create mode 100644 applications/support-desk/test/persistence.py create mode 100644 applications/support-desk/test/requirements.txt diff --git a/.github/workflows/support-desk.yml b/.github/workflows/support-desk.yml new file mode 100644 index 00000000..1b6f10f5 --- /dev/null +++ b/.github/workflows/support-desk.yml @@ -0,0 +1,32 @@ +name: Support desk checks +on: + pull_request: + paths: [applications/support-desk/**, .github/workflows/support-desk.yml] + push: + paths: [applications/support-desk/**, .github/workflows/support-desk.yml] +permissions: + contents: read +jobs: + rails-check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/support-desk + env: + SECRET_KEY_BASE: ci-configuration-check-only-ci-configuration-check-only + PGHOST: unused.example.invalid + PGUSER: support_desk_app + PGPASSWORD: unused + PGSSLROOTCERT: /etc/ssl/certs/ca-certificates.crt + COOKIE_SECURE: '1' + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.3' + bundler-cache: true + working-directory: applications/support-desk + - run: bundle exec rails zeitwerk:check + - run: bundle exec rails routes + - run: find app config db test -name '*.rb' -print0 | xargs -0 -n 1 ruby -c +# Real database/HTTP acceptance uses a dedicated Cloud service, never CI secrets. diff --git a/applications/support-desk/.env.example b/applications/support-desk/.env.example new file mode 100644 index 00000000..022496f8 --- /dev/null +++ b/applications/support-desk/.env.example @@ -0,0 +1,12 @@ +# Load explicitly with set -a; source .deployment/app.env; set +a +RAILS_ENV=development +SECRET_KEY_BASE=REPLACE_WITH_128_HEX_CHARACTERS +COOKIE_SECURE=0 +PGHOST=YOUR_SERVICE_DIRECT_HOSTNAME +PGPORT=5432 +PGDATABASE=postgres +PGUSER=support_desk_app +PGPASSWORD=YOUR_RUNTIME_PASSWORD +PGSSLROOTCERT=/absolute/path/to/.deployment/postgres-ca.pem +RAILS_MAX_THREADS=5 +# Production requires HTTPS, COOKIE_SECURE=1, and APP_HOSTS=your-app.example diff --git a/applications/support-desk/.gitignore b/applications/support-desk/.gitignore new file mode 100644 index 00000000..8003369e --- /dev/null +++ b/applications/support-desk/.gitignore @@ -0,0 +1,8 @@ +.bundle/ +vendor/bundle/ +.deployment/ +.env +log/* +tmp/* +public/assets/ +__pycache__/ diff --git a/applications/support-desk/.ruby-version b/applications/support-desk/.ruby-version new file mode 100644 index 00000000..b347b11e --- /dev/null +++ b/applications/support-desk/.ruby-version @@ -0,0 +1 @@ +3.2.3 diff --git a/applications/support-desk/Gemfile b/applications/support-desk/Gemfile new file mode 100644 index 00000000..8977e4d5 --- /dev/null +++ b/applications/support-desk/Gemfile @@ -0,0 +1,9 @@ +source "https://rubygems.org" +ruby ">= 3.2.0", "< 4.0" +gem "rails", "8.1.4" +gem "pg", "1.6.2" +gem "puma", "6.6.1" +gem "bcrypt", "3.1.20" +gem "propshaft", "1.2.1" +gem "importmap-rails", "2.2.2" +gem "turbo-rails", "2.0.17" diff --git a/applications/support-desk/Gemfile.lock b/applications/support-desk/Gemfile.lock new file mode 100644 index 00000000..f9e8dc95 --- /dev/null +++ b/applications/support-desk/Gemfile.lock @@ -0,0 +1,259 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.4) + actionpack (= 8.1.4) + activesupport (= 8.1.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.4) + actionpack (= 8.1.4) + activejob (= 8.1.4) + activerecord (= 8.1.4) + activestorage (= 8.1.4) + activesupport (= 8.1.4) + mail (>= 2.8.0) + actionmailer (8.1.4) + actionpack (= 8.1.4) + actionview (= 8.1.4) + activejob (= 8.1.4) + activesupport (= 8.1.4) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.4) + actionview (= 8.1.4) + activesupport (= 8.1.4) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.4) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.4) + activerecord (= 8.1.4) + activestorage (= 8.1.4) + activesupport (= 8.1.4) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.4) + activesupport (= 8.1.4) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.4) + activesupport (= 8.1.4) + globalid (>= 0.3.6) + activemodel (8.1.4) + activesupport (= 8.1.4) + activerecord (8.1.4) + activemodel (= 8.1.4) + activesupport (= 8.1.4) + timeout (>= 0.4.0) + activestorage (8.1.4) + actionpack (= 8.1.4) + activejob (= 8.1.4) + activerecord (= 8.1.4) + activesupport (= 8.1.4) + marcel (~> 1.0) + activesupport (8.1.4) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + base64 (0.3.0) + bcrypt (3.1.20) + bigdecimal (4.1.3) + builder (3.3.0) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + drb (2.2.3) + erb (6.0.7) + erubi (1.13.1) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (3.0.2) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + net-imap (0.6.7) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.4.0) + timeout + net-smtp (0.5.2) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.4) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.2.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + puma (6.6.1) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.4) + actioncable (= 8.1.4) + actionmailbox (= 8.1.4) + actionmailer (= 8.1.4) + actionpack (= 8.1.4) + actiontext (= 8.1.4) + actionview (= 8.1.4) + activejob (= 8.1.4) + activemodel (= 8.1.4) + activerecord (= 8.1.4) + activestorage (= 8.1.4) + activesupport (= 8.1.4) + bundler (>= 1.15.0) + railties (= 8.1.4) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.4) + actionpack (= 8.1.4) + activesupport (= 8.1.4) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rake (13.4.2) + rbs (4.1.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + securerandom (0.4.1) + thor (1.5.0) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.17) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + useragent (0.16.11) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (= 3.1.20) + importmap-rails (= 2.2.2) + pg (= 1.6.2) + propshaft (= 1.2.1) + puma (= 6.6.1) + rails (= 8.1.4) + turbo-rails (= 2.0.17) + +RUBY VERSION + ruby 3.2.3p157 + +BUNDLED WITH + 2.6.9 diff --git a/applications/support-desk/README.md b/applications/support-desk/README.md new file mode 100644 index 00000000..0e8c4077 --- /dev/null +++ b/applications/support-desk/README.md @@ -0,0 +1,214 @@ +# Support desk + +Create a ticket, keep its replies together, and assign one staff owner with **Rails**, **Active Record**, **pg**, and **Hotwire/Turbo** on **ClickHouse Managed Postgres (public beta)**. Customers see and reply to their own tickets. Staff see the queue, claim unassigned tickets, reply to tickets they own, and close or reopen them. + +Two staff members can press **Claim ticket** together. The ticket row lock makes the second request observe the first assignment, so exactly one succeeds. The same lock coordinates replies and closure: an accepted reply happens before closure; a closed ticket rejects new replies until its assigned owner reopens it. + +[ClickHouse Cloud](https://clickhouse.com/cloud) is the managed data platform. [ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres/overview) is the transactional Postgres service used here. This app needs only that database service. + +## Design and behavior + +Three tables hold users, tickets, and replies. Native Rails `has_secure_password` stores bcrypt password digests. A ticket has one customer, one nullable assignee, an open/closed status, and a closing timestamp. Replies have a ticket, an author, a body, and a creation timestamp. Foreign keys protect references; checks reject blank/oversize messages and inconsistent status/timestamps. A single assignee column represents one owner; Active Record `with_lock` prevents competing claims from overwriting it. + +| Action | Permission and result | +| --- | --- | +| Create ticket with initial message | Customer only; ticket and reply commit together; `303` | +| View ticket | Its customer or any staff member; other customers get `404` | +| Claim | Staff only; unassigned open ticket, or retry by its existing owner; `303` | +| Claim owned by another staff member, or closed | `409`, preserving existing owner | +| Add reply | Customer of open ticket, or its assigned staff owner; `303` | +| Reply to closed ticket | `409`; no new reply | +| Close/reopen | Assigned staff owner only; `303`; repeats preserve closing time | +| Missing/invalid fields | `400` for missing/malformed parameters; `422` for model validation | + +The server derives customer/author identity from its authenticated user. Posted user IDs cannot choose an owner. Staff without ownership cannot reply or change status (`403`); customers cannot claim tickets or change their status (`403`). Each state change is a CSRF-protected POST. Sign out uses DELETE with Rails' CSRF protection. + +The queue contains 20 tickets per page with newer/older navigation. The conversation defaults to the newest 50 replies in chronological order, with visible page labels and older/newer navigation. Replies order by `created_at, id`, so equal timestamps have a stable tie breaker. Page inputs must be integers from 1 to 999. Successful Turbo forms redirect with `303`; invalid forms render `422`. The conversation uses a Turbo Frame for replies, claims and status changes. Ordinary server-rendered forms also work without JavaScript. + +## Setup in Linux + +Use Ruby 3.2 or newer compatible with Rails 8.1, Bundler 2.6.9, PostgreSQL client tools, Git, `jq`, OpenSSL and a Cloud account with Managed Postgres access. Validation used Ubuntu 24.04's Ruby 3.2.3 distribution package, Rails 8.1.4 and pg 1.6.2. Use a maintained Ruby runtime for your hosting environment. Install dependencies only in Linux; an optional dedicated OrbStack VM keeps them away from macOS: + +```sh +orb create --memory 3G --cpus 2 ubuntu:24.04 support-desk-dev +orb -m support-desk-dev +sudo apt-get update +sudo apt-get install -y ruby ruby-dev build-essential libpq-dev postgresql-client git curl jq openssl +git clone https://github.com/ClickHouse/examples.git ~/examples +cd ~/examples/applications/support-desk +sudo gem install bundler -v 2.6.9 --no-document +bundle _2.6.9_ config set --local path vendor/bundle +bundle _2.6.9_ install +umask 077 +mkdir -p .deployment +``` + +`Gemfile.lock` pins the tested gem set. Importmap and the Turbo gem supply local JavaScript; Propshaft serves assets. There is no Node.js build, Redis, SQLite, cache database, background-job database, or external authentication service. + +### 1. Create a dedicated Cloud service + +Provision where you manage Cloud resources. Install [clickhousectl](https://clickhouse.com/docs/interfaces/cli), and authenticate with an Admin API key. Use the interactive prompt to keep credentials out of shell history: + +```sh +curl -fsSL https://clickhouse.com/cli | sh +export PATH="$HOME/.local/bin:$PATH" +clickhousectl --version +clickhousectl cloud auth login --interactive +clickhousectl cloud org list +umask 077 +mkdir -p .deployment +``` + +Set a private `.deployment/resources.env` file using your organization and an available region/size: + +```dotenv +CH_ORG_ID=YOUR_ORGANIZATION_UUID +DEPLOYMENT_NAME=support-desk-example +CLOUD_REGION=us-east-1 +PG_SIZE=c6gd.large +``` + +Validation used `c6gd.large`, Postgres 18 and no HA. Review [ClickHouse Managed Postgres pricing](https://clickhouse.com/docs/products/managed-postgres/pricing) before creation. Compute, storage, backups and network usage can incur charges. Stopping Rails or the VM does not delete a Cloud service. + +Create once. Preserve its response, including the initial administrator password: + +```sh +source .deployment/resources.env +clickhousectl cloud postgres create \ + --org-id "$CH_ORG_ID" --name "$DEPLOYMENT_NAME" \ + --provider aws --region "$CLOUD_REGION" --size "$PG_SIZE" \ + --pg-version 18 --ha-type none --tag project=support-desk --json \ + > .deployment/postgres-create.json +PG_SERVICE_ID="$(jq -er '.id' .deployment/postgres-create.json)" +printf 'PG_SERVICE_ID=%s\n' "$PG_SERVICE_ID" >> .deployment/resources.env +``` + +Repeat `get` until `state` is `running`, without repeating creation: + +```sh +clickhousectl cloud postgres get "$PG_SERVICE_ID" \ + --org-id "$CH_ORG_ID" --json > .deployment/postgres-status.json +jq '{id, state, size, postgresVersion}' .deployment/postgres-status.json +clickhousectl cloud postgres certs get "$PG_SERVICE_ID" \ + --org-id "$CH_ORG_ID" --output .deployment/postgres-ca.pem +``` + +Use `--output` for a PEM certificate file. JSON certificate metadata is not a CA bundle. If creation was interrupted, reconcile the exact name/ID against `cloud postgres list` before creating another service. If provisioning outside Linux, transfer the private create receipt and CA into the Linux app's `.deployment` directory for bootstrap; don't copy Cloud API keys. + +### 2. Bootstrap roles, migrate, and seed + +The remaining commands run in Linux from the application directory. Generate role passwords once and retain the file on retries: + +```sh +umask 077 +cat > .deployment/passwords.env <p,.login>p { + color:var(--muted); + line-height:1.6} +.intro { + margin-bottom:42px} +.heading { + display:flex; + align-items:center; + justify-content:space-between; + gap:20px} +.section-heading { + display:flex; + align-items:center; + justify-content:space-between; + margin-bottom:14px} +.section-heading span { + font-size:13px; + color:var(--muted)} +.ticket-list { + border:1px solid var(--line); + border-radius:13px; + overflow:hidden; + background:white} +.ticket-row { + padding:23px 27px; + display:flex; + align-items:center; + justify-content:space-between; + gap:20px; + border-bottom:1px solid var(--line)} +.ticket-row:last-child { + border:0} +.ticket-row:hover { + background:#fbfbf9} +.ticket-number { + font-size:11px; + letter-spacing:1px; + color:#916b46} +.ticket-row p { + font-size:13px; + color:var(--muted); + margin:0} +.row-status { + display:flex; + align-items:center; + gap:20px; + font-size:12px; + color:var(--muted)} +.row-status b { + font-size:20px; + color:var(--blue)} +.badge { + display:inline-block; + font-size:11px; + font-weight:700; + padding:6px 11px; + border-radius:20px; + background:#e9eff7; + color:var(--blue)} +.badge.closed { + background:#ebede9; + color:#6c7663} +button,input[type=submit],.button { + border:0; + background:var(--blue); + color:#fff; + border-radius:8px; + padding:12px 17px; + font:600 14px 'DM Sans',sans-serif; + cursor:pointer; + display:inline-block; + white-space:nowrap} +.secondary { + background:#e9edf1; + color:var(--blue)} +.quiet { + background:none; + color:var(--muted); + padding:0} +.empty { + border:1px dashed #c9cdd1; + border-radius:10px; + padding:25px; + color:var(--muted); + font-size:14px} +.notice,.alert { + padding:15px 18px; + border-radius:7px; + font-size:14px} +.notice { + background:#eaf0e5; + border-left:3px solid #65815d} +.alert { + background:#f8e9e3; + border-left:3px solid #b06142} +.login,.new-ticket { + max-width:570px; + margin:25px auto} +.login form,.new-ticket form { + margin-top:30px} +label { + display:block; + font-size:13px; + font-weight:600; + margin-bottom:9px} +input:not([type=submit]),textarea { + width:100%; + padding:12px; + border:1px solid #c9cdd1; + border-radius:8px; + font:inherit; + margin-bottom:24px; + background:#fff} +textarea { + resize:vertical} +.back { + font-size:13px; + color:var(--muted)} +.ticket-intro { + margin:29px 0} +.ticket-meta { + display:flex; + align-items:center; + gap:20px; + color:var(--muted); + font-size:12px} +.conversation-layout { + display:grid; + grid-template-columns:minmax(0,1fr) 250px; + gap:30px; + margin-top:30px} +.reply { + border:1px solid var(--line); + background:white; + border-radius:12px; + padding:24px; + margin-bottom:16px} +.reply-author { + display:flex; + justify-content:space-between; + gap:15px; + font-size:13px} +.reply-author span { + color:var(--muted); + font-size:11px} +.reply p { + white-space:pre-wrap; + overflow-wrap:anywhere; + line-height:1.6; + font-size:14px; + margin:20px 0 0} +.reply-form { + margin-top:26px} +.reply-form textarea { + margin-bottom:12px} +aside { + border:1px solid var(--line); + border-radius:12px; + padding:22px; + background:#eeefeb; + height:fit-content} +aside h2 { + font-size:16px; + margin-top:0} +aside p { + font-size:13px; + color:var(--muted); + line-height:1.6} +.owner { + color:var(--blue)} +footer { + border-top:1px solid var(--line); + padding:26px 6vw; + font-size:12px; + color:var(--muted)} +@media(max-width:700px) { + header { + padding:20px; + flex-wrap:wrap; + gap:16px} +nav { + gap:15px} +main { + padding:30px 20px} +h1 { + font-size:31px} +.heading,.ticket-row { + align-items:flex-start; + flex-direction:column} +.row-status { + gap:15px} +.conversation-layout { + grid-template-columns:1fr} +.ticket-meta { + flex-wrap:wrap; + gap:12px} +.reply-author { + flex-direction:column; + gap:6px} +} + +.pagination { display: flex; justify-content: space-between; margin: 18px 0; font-size: 13px; color: var(--blue); } +.timeline-note { color: var(--muted); font-size: 12px; } diff --git a/applications/support-desk/app/controllers/application_controller.rb b/applications/support-desk/app/controllers/application_controller.rb new file mode 100644 index 00000000..13e311a0 --- /dev/null +++ b/applications/support-desk/app/controllers/application_controller.rb @@ -0,0 +1,40 @@ +class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + before_action :require_user + helper_method :current_user + rescue_from ActiveRecord::RecordNotFound, with: -> { head :not_found } + rescue_from Ticket::Forbidden, with: -> { head :forbidden } + rescue_from ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid, with: :database_unavailable + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + + def require_user + redirect_to new_session_path unless current_user + end + + def visible_tickets + current_user.staff? ? Ticket.all : Ticket.where(customer: current_user) + end + + def page_number(key) + value = params.fetch(key, "1").to_s + raise ActionController::BadRequest, "Invalid page" unless value.match?(/\A[1-9]\d{0,2}\z/) + value.to_i + end + + def prepare_conversation + @reply_page = page_number(:reply_page) + rows = @ticket.replies.reorder(created_at: :desc, id: :desc) + .includes(:author).offset((@reply_page - 1) * 50).limit(51).to_a + @older_replies = rows.length > 50 + @replies = rows.first(50).reverse + end + + def database_unavailable + render plain: "The database is busy. Refresh and try again.", status: :service_unavailable + end +end diff --git a/applications/support-desk/app/controllers/replies_controller.rb b/applications/support-desk/app/controllers/replies_controller.rb new file mode 100644 index 00000000..514fd2c2 --- /dev/null +++ b/applications/support-desk/app/controllers/replies_controller.rb @@ -0,0 +1,16 @@ +class RepliesController < ApplicationController + def create + @ticket = visible_tickets.find(params[:ticket_id]) + @ticket.reply_by!(current_user, params.expect(reply: [:body])[:body]) + redirect_to @ticket, status: :see_other, notice: "Reply added." + rescue ActiveRecord::RecordInvalid => error + @reply = error.record + prepare_conversation + render "tickets/show", status: :unprocessable_entity + rescue Ticket::Conflict => error + @reply = Reply.new + flash.now[:alert] = error.message + prepare_conversation + render "tickets/show", status: :conflict + end +end diff --git a/applications/support-desk/app/controllers/sessions_controller.rb b/applications/support-desk/app/controllers/sessions_controller.rb new file mode 100644 index 00000000..a032028c --- /dev/null +++ b/applications/support-desk/app/controllers/sessions_controller.rb @@ -0,0 +1,24 @@ +class SessionsController < ApplicationController + skip_before_action :require_user, only: [:new, :create] + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { render plain: "Too many sign-in attempts. Try again later.", status: :too_many_requests } + + def new; end + + def create + user = User.find_by(email: params[:email].to_s.strip.downcase) + if user&.authenticate(params[:password].to_s) + reset_session + session[:user_id] = user.id + redirect_to tickets_path, status: :see_other + else + flash.now[:alert] = "Email or password is incorrect." + render :new, status: :unprocessable_entity + end + end + + def destroy + reset_session + redirect_to new_session_path, status: :see_other + end +end diff --git a/applications/support-desk/app/controllers/tickets_controller.rb b/applications/support-desk/app/controllers/tickets_controller.rb new file mode 100644 index 00000000..be0c335d --- /dev/null +++ b/applications/support-desk/app/controllers/tickets_controller.rb @@ -0,0 +1,68 @@ +class TicketsController < ApplicationController + before_action :load_ticket, only: [:show, :claim, :close, :reopen] + + def index + @page = page_number(:page) + rows = visible_tickets.includes(:customer, :assignee).order(created_at: :desc, id: :desc) + .offset((@page - 1) * 20).limit(21).to_a + @next_page = rows.length > 20 + @tickets = rows.first(20) + end + + def new + head :forbidden and return if current_user.staff? + @ticket = Ticket.new + end + + def create + head :forbidden and return if current_user.staff? + @ticket = Ticket.new(params.expect(ticket: [:subject])) + @ticket.customer = current_user + body = params[:body] + raise ActionController::BadRequest, "Message must be text" unless body.is_a?(String) + Ticket.transaction do + @ticket.save! + @ticket.replies.create!(author: current_user, body: body) + end + redirect_to @ticket, status: :see_other + rescue ActiveRecord::RecordInvalid => error + @ticket.errors.add(:base, error.record.errors.full_messages.join(", ")) unless error.record == @ticket + @body = body + render :new, status: :unprocessable_entity + end + + def show + prepare_conversation + @reply = Reply.new + end + + def claim + @ticket.claim_by!(current_user) + redirect_to @ticket, status: :see_other, notice: "This ticket is assigned to you." + rescue Ticket::Conflict => error + render_conflict(error) + end + + def close + @ticket.change_status_by!(current_user, "closed") + redirect_to @ticket, status: :see_other, notice: "Ticket closed." + end + + def reopen + @ticket.change_status_by!(current_user, "open") + redirect_to @ticket, status: :see_other, notice: "Ticket reopened." + end + + private + + def load_ticket + @ticket = visible_tickets.find(params[:id]) + end + + def render_conflict(error) + @reply = Reply.new + flash.now[:alert] = error.message + prepare_conversation + render :show, status: :conflict + end +end diff --git a/applications/support-desk/app/javascript/application.js b/applications/support-desk/app/javascript/application.js new file mode 100644 index 00000000..ffef3b08 --- /dev/null +++ b/applications/support-desk/app/javascript/application.js @@ -0,0 +1 @@ +import "@hotwired/turbo-rails" diff --git a/applications/support-desk/app/models/application_record.rb b/applications/support-desk/app/models/application_record.rb new file mode 100644 index 00000000..b63caeb8 --- /dev/null +++ b/applications/support-desk/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/applications/support-desk/app/models/reply.rb b/applications/support-desk/app/models/reply.rb new file mode 100644 index 00000000..7cdefd45 --- /dev/null +++ b/applications/support-desk/app/models/reply.rb @@ -0,0 +1,6 @@ +class Reply < ApplicationRecord + belongs_to :ticket + belongs_to :author, class_name: "User" + normalizes :body, with: ->(body) { body.strip } + validates :body, presence: true, length: { maximum: 4000 } +end diff --git a/applications/support-desk/app/models/ticket.rb b/applications/support-desk/app/models/ticket.rb new file mode 100644 index 00000000..786619d6 --- /dev/null +++ b/applications/support-desk/app/models/ticket.rb @@ -0,0 +1,37 @@ +class Ticket < ApplicationRecord + class Conflict < StandardError; end + class Forbidden < StandardError; end + + belongs_to :customer, class_name: "User" + belongs_to :assignee, class_name: "User", optional: true + has_many :replies, -> { order(:created_at, :id) }, dependent: :restrict_with_exception + normalizes :subject, with: ->(subject) { subject.strip } + validates :subject, presence: true, length: { maximum: 120 } + enum :status, { open: "open", closed: "closed" }, validate: true + + def claim_by!(user) + raise Forbidden unless user.staff? + with_lock do + raise Conflict, "This ticket is closed." if closed? + raise Conflict, "Another staff member owns this ticket." if assignee_id && assignee_id != user.id + update!(assignee: user) unless assignee_id + end + end + + def reply_by!(user, body) + with_lock do + authorized = user.staff? ? assignee_id == user.id : customer_id == user.id + raise Forbidden unless authorized + raise Conflict, "Closed tickets cannot receive replies. Reopen the ticket first." if closed? + replies.create!(author: user, body: body) + end + end + + def change_status_by!(user, desired) + with_lock do + raise Forbidden unless user.staff? && assignee_id == user.id + raise ArgumentError unless %w[open closed].include?(desired) + update!(status: desired, closed_at: desired == "closed" ? (closed_at || Time.current) : nil) + end + end +end diff --git a/applications/support-desk/app/models/user.rb b/applications/support-desk/app/models/user.rb new file mode 100644 index 00000000..4996dd2a --- /dev/null +++ b/applications/support-desk/app/models/user.rb @@ -0,0 +1,8 @@ +class User < ApplicationRecord + has_secure_password + normalizes :email, with: ->(email) { email.strip.downcase } + validates :name, presence: true, length: { maximum: 80 } + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 12 }, if: -> { new_record? || password.present? } + enum :role, { customer: "customer", staff: "staff" }, validate: true +end diff --git a/applications/support-desk/app/views/layouts/application.html.erb b/applications/support-desk/app/views/layouts/application.html.erb new file mode 100644 index 00000000..150af617 --- /dev/null +++ b/applications/support-desk/app/views/layouts/application.html.erb @@ -0,0 +1,20 @@ + + + + Support desk + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + +
<%= link_to "◌ Support desk", tickets_path, class: "brand" %> + <% if current_user %> + + <% end %> +
+
<%= yield %>
+
A clear place for every conversation.
+ + diff --git a/applications/support-desk/app/views/sessions/new.html.erb b/applications/support-desk/app/views/sessions/new.html.erb new file mode 100644 index 00000000..30b07bb1 --- /dev/null +++ b/applications/support-desk/app/views/sessions/new.html.erb @@ -0,0 +1,11 @@ + diff --git a/applications/support-desk/app/views/tickets/index.html.erb b/applications/support-desk/app/views/tickets/index.html.erb new file mode 100644 index 00000000..bc984f5a --- /dev/null +++ b/applications/support-desk/app/views/tickets/index.html.erb @@ -0,0 +1,19 @@ +
+

<%= current_user.staff? ? "THE SUPPORT QUEUE" : "YOUR CONVERSATIONS" %>

+

<%= current_user.staff? ? "Good help starts here." : "How can we help?" %>

<%= current_user.staff? ? "Pick a conversation, take ownership, and follow it through." : "Start a ticket and keep the whole conversation together." %>

+ <% unless current_user.staff? %><%= link_to "New ticket +", new_ticket_path, class: "button" %><% end %>
+
+

<%= current_user.staff? ? "All tickets" : "Your tickets" %>

Page <%= @page %> · <%= @tickets.length %> shown
+
+ <% @tickets.each do |ticket| %> + <%= link_to ticket, class: "ticket-row", id: dom_id(ticket) do %> +
#<%= ticket.id %>

<%= ticket.subject %>

<%= current_user.staff? ? ticket.customer.name : "Created" %> · <%= ticket.created_at.strftime("%-d %b, %H:%M") %>

+
<%= ticket.status.capitalize %><%= ticket.assignee&.name || "Unassigned" %>↗
+ <% end %> + <% end %> + <% if @tickets.empty? %>
No conversations yet. Your first ticket starts here.
<% end %> +
+ diff --git a/applications/support-desk/app/views/tickets/new.html.erb b/applications/support-desk/app/views/tickets/new.html.erb new file mode 100644 index 00000000..1454f09e --- /dev/null +++ b/applications/support-desk/app/views/tickets/new.html.erb @@ -0,0 +1,9 @@ +<%= link_to "← Back to tickets", tickets_path, class: "back" %> +

TELL US WHAT'S HAPPENING

Start a conversation.

A clear subject and a little context help us get you an answer.

+ <% if @ticket.errors.any? %><% end %> + <%= form_with model: @ticket do |form| %> + <%= form.label :subject %><%= form.text_field :subject, maxlength: 120, required: true %> + <%= label_tag :body, "Message" %><%= text_area_tag :body, @body, rows: 6, maxlength: 4000, required: true %> + <%= form.submit "Create ticket" %> + <% end %> +
diff --git a/applications/support-desk/app/views/tickets/show.html.erb b/applications/support-desk/app/views/tickets/show.html.erb new file mode 100644 index 00000000..ccc4f801 --- /dev/null +++ b/applications/support-desk/app/views/tickets/show.html.erb @@ -0,0 +1,35 @@ +<%= link_to "← Back to tickets", tickets_path, class: "back" %> +<%= turbo_frame_tag "conversation", data: { turbo_action: "advance" } do %> +

CONVERSATION #<%= @ticket.id %>

<%= @ticket.subject %>

+
<%= @ticket.status.capitalize %>From <%= @ticket.customer.name %>Assigned to <%= @ticket.assignee&.name || "nobody yet" %>
+
+ <% if flash[:notice] %>

<%= flash[:notice] %>

<% end %> + <% if flash[:alert] %><% end %> + <% if @reply&.errors&.any? %><% end %> +
+
+

<%= @reply_page == 1 ? "Latest replies" : "Earlier replies" %> · page <%= @reply_page %> · <%= @replies.length %> shown

+ + <% @replies.each do |reply| %> +
<%= reply.author.name %><%= reply.author.role %> · <%= reply.created_at.strftime("%-d %b, %H:%M") %>

<%= reply.body %>

+ <% end %> + <% authorized = current_user.staff? ? @ticket.assignee_id == current_user.id : @ticket.customer_id == current_user.id %> + <% if @ticket.open? && authorized %> + <%= form_with model: [@ticket, (@reply || Reply.new)], class: "reply-form" do |form| %> + <%= form.label :body, "Your reply" %><%= form.text_area :body, rows: 4, maxlength: 4000, required: true %><%= form.submit "Add reply" %> + <% end %> + <% elsif @ticket.closed? %>
This ticket is closed. Staff must reopen it before anyone can reply.
+ <% else %>
Claim this ticket to join the conversation.
<% end %> +
+ +
+<% end %> diff --git a/applications/support-desk/bin/rails b/applications/support-desk/bin/rails new file mode 100755 index 00000000..efc03774 --- /dev/null +++ b/applications/support-desk/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/applications/support-desk/config.ru b/applications/support-desk/config.ru new file mode 100644 index 00000000..ce7479a1 --- /dev/null +++ b/applications/support-desk/config.ru @@ -0,0 +1,3 @@ +require_relative "config/environment" +run Rails.application +Rails.application.load_server diff --git a/applications/support-desk/config/application.rb b/applications/support-desk/config/application.rb new file mode 100644 index 00000000..6895780c --- /dev/null +++ b/applications/support-desk/config/application.rb @@ -0,0 +1,24 @@ +require_relative "boot" +require "rails" +require "active_support/core_ext/integer/time" +require "active_support/core_ext/numeric/bytes" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +Bundler.require(*Rails.groups) + +module SupportDesk + class Application < Rails::Application + config.load_defaults 8.1 + config.secret_key_base = ENV.fetch("SECRET_KEY_BASE") + config.time_zone = "UTC" + config.filter_parameters += [:password, :password_confirmation] + config.action_dispatch.cookies_same_site_protection = :lax + config.session_store :cookie_store, key: "_support_desk_session", httponly: true, + same_site: :lax, secure: ENV.fetch("COOKIE_SECURE", "1") == "1", expire_after: 12.hours + config.action_controller.default_protect_from_forgery = true + # Keep migrations authoritative. A dumped schema would try CREATE SCHEMA, + # but this example intentionally reserves schema creation for bootstrap. + config.active_record.dump_schema_after_migration = false + end +end diff --git a/applications/support-desk/config/boot.rb b/applications/support-desk/config/boot.rb new file mode 100644 index 00000000..fbd15a53 --- /dev/null +++ b/applications/support-desk/config/boot.rb @@ -0,0 +1,2 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" diff --git a/applications/support-desk/config/database.yml b/applications/support-desk/config/database.yml new file mode 100644 index 00000000..5d4c1b6e --- /dev/null +++ b/applications/support-desk/config/database.yml @@ -0,0 +1,22 @@ +default: &default + adapter: postgresql + encoding: unicode + database: <%= ENV.fetch("PGDATABASE", "postgres") %> + host: <%= ENV.fetch("PGHOST") %> + port: <%= ENV.fetch("PGPORT", "5432") %> + username: <%= ENV.fetch("PGUSER") %> + password: <%= ENV.fetch("PGPASSWORD").to_json %> + sslmode: verify-full + sslrootcert: <%= ENV.fetch("PGSSLROOTCERT").to_json %> + connect_timeout: 10 + pool: <%= ENV.fetch("RAILS_MAX_THREADS", "5") %> + schema_search_path: support_desk,public + variables: + statement_timeout: 10000 + lock_timeout: 5000 + +development: + <<: *default +production: + <<: *default +# No automatically created local test, cache, queue or cable database. diff --git a/applications/support-desk/config/environment.rb b/applications/support-desk/config/environment.rb new file mode 100644 index 00000000..40c19d29 --- /dev/null +++ b/applications/support-desk/config/environment.rb @@ -0,0 +1,2 @@ +require_relative "application" +Rails.application.initialize! diff --git a/applications/support-desk/config/environments/development.rb b/applications/support-desk/config/environments/development.rb new file mode 100644 index 00000000..7001217c --- /dev/null +++ b/applications/support-desk/config/environments/development.rb @@ -0,0 +1,10 @@ +Rails.application.configure do + config.enable_reloading = true + config.eager_load = false + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :memory_store, { size: 16.megabytes } + config.active_record.migration_error = :page_load + config.active_record.verbose_query_logs = false + config.hosts = ["localhost", "127.0.0.1"] +end diff --git a/applications/support-desk/config/environments/production.rb b/applications/support-desk/config/environments/production.rb new file mode 100644 index 00000000..3694d481 --- /dev/null +++ b/applications/support-desk/config/environments/production.rb @@ -0,0 +1,11 @@ +Rails.application.configure do + config.enable_reloading = false + config.eager_load = true + config.consider_all_requests_local = false + config.action_controller.perform_caching = false + config.cache_store = :memory_store, { size: 16.megabytes } + config.force_ssl = true + config.hosts = ENV.fetch("APP_HOSTS").split(",") + config.log_level = :info + config.public_file_server.enabled = true +end diff --git a/applications/support-desk/config/importmap.rb b/applications/support-desk/config/importmap.rb new file mode 100644 index 00000000..3fded916 --- /dev/null +++ b/applications/support-desk/config/importmap.rb @@ -0,0 +1,2 @@ +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" diff --git a/applications/support-desk/config/puma.rb b/applications/support-desk/config/puma.rb new file mode 100644 index 00000000..39e6dd2c --- /dev/null +++ b/applications/support-desk/config/puma.rb @@ -0,0 +1,4 @@ +threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", "5")) +threads threads_count, threads_count +bind "tcp://127.0.0.1:#{ENV.fetch('PORT', '3000')}" +plugin :tmp_restart diff --git a/applications/support-desk/config/routes.rb b/applications/support-desk/config/routes.rb new file mode 100644 index 00000000..12e8def6 --- /dev/null +++ b/applications/support-desk/config/routes.rb @@ -0,0 +1,10 @@ +Rails.application.routes.draw do + root "tickets#index" + resource :session, only: [:new, :create, :destroy] + resources :tickets, only: [:index, :new, :create, :show] do + post :claim, on: :member + post :close, on: :member + post :reopen, on: :member + resources :replies, only: [:create] + end +end diff --git a/applications/support-desk/db/migrate/20261002000000_create_support_desk.rb b/applications/support-desk/db/migrate/20261002000000_create_support_desk.rb new file mode 100644 index 00000000..47c97b0f --- /dev/null +++ b/applications/support-desk/db/migrate/20261002000000_create_support_desk.rb @@ -0,0 +1,32 @@ +class CreateSupportDesk < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email, null: false + t.string :name, null: false + t.string :role, null: false, default: "customer" + t.string :password_digest, null: false + t.timestamps + end + add_index :users, :email, unique: true + add_check_constraint :users, "role IN ('customer', 'staff')", name: "valid_user_role" + create_table :tickets do |t| + t.references :customer, null: false, foreign_key: { to_table: :users } + t.references :assignee, foreign_key: { to_table: :users } + t.string :subject, limit: 120, null: false + t.string :status, null: false, default: "open" + t.datetime :closed_at + t.timestamps + end + add_check_constraint :tickets, "length(btrim(subject)) BETWEEN 1 AND 120", name: "ticket_subject_length" + add_check_constraint :tickets, "(status = 'open' AND closed_at IS NULL) OR (status = 'closed' AND closed_at IS NOT NULL)", name: "ticket_status_timestamp" + add_index :tickets, [:status, :created_at, :id], name: "ticket_queue" + create_table :replies do |t| + t.references :ticket, null: false, foreign_key: true + t.references :author, null: false, foreign_key: { to_table: :users } + t.text :body, null: false + t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" } + end + add_check_constraint :replies, "length(btrim(body)) BETWEEN 1 AND 4000", name: "reply_body_length" + add_index :replies, [:ticket_id, :created_at, :id], name: "reply_timeline" + end +end diff --git a/applications/support-desk/db/seeds.rb b/applications/support-desk/db/seeds.rb new file mode 100644 index 00000000..e1a0adb3 --- /dev/null +++ b/applications/support-desk/db/seeds.rb @@ -0,0 +1,20 @@ +password = ENV.fetch("DEMO_PASSWORD") +raise "DEMO_PASSWORD must contain at least 12 characters" if password.length < 12 +User.transaction do + [["alex@example.test", "Alex", "customer"], ["sam@example.test", "Sam", "customer"], + ["morgan@example.test", "Morgan", "staff"], ["jordan@example.test", "Jordan", "staff"]].each do |email, name, role| + User.find_or_create_by!(email: email) do |user| + user.name = name + user.role = role + user.password = password + end + end + customer = User.find_by!(email: "alex@example.test") + unless Ticket.exists?(subject: "Help connecting a new workspace") + Ticket.transaction do + ticket = Ticket.create!(customer: customer, subject: "Help connecting a new workspace") + ticket.replies.create!(author: customer, body: "I have a new workspace and would like help with the first connection.") + end + end +end +puts "Demo customers, staff and one ticket are ready." diff --git a/applications/support-desk/sql/bootstrap.sql b/applications/support-desk/sql/bootstrap.sql new file mode 100644 index 00000000..c326add9 --- /dev/null +++ b/applications/support-desk/sql/bootstrap.sql @@ -0,0 +1,10 @@ +\set ON_ERROR_STOP on +\getenv migrator_password SUPPORT_MIGRATOR_PASSWORD +\getenv app_password SUPPORT_APP_PASSWORD +CREATE ROLE support_desk_migrator LOGIN PASSWORD :'migrator_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE ROLE support_desk_app LOGIN PASSWORD :'app_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE SCHEMA support_desk AUTHORIZATION support_desk_migrator; +REVOKE ALL ON SCHEMA support_desk FROM PUBLIC; +GRANT USAGE ON SCHEMA support_desk TO support_desk_app; +ALTER ROLE support_desk_migrator SET search_path = support_desk, public; +ALTER ROLE support_desk_app SET search_path = support_desk, public; diff --git a/applications/support-desk/sql/cleanup.sql b/applications/support-desk/sql/cleanup.sql new file mode 100644 index 00000000..58d2867b --- /dev/null +++ b/applications/support-desk/sql/cleanup.sql @@ -0,0 +1,5 @@ +\set ON_ERROR_STOP on +-- Destructive: run explicitly as service administrator after stopping the app. +DROP SCHEMA support_desk CASCADE; +DROP ROLE support_desk_app; +DROP ROLE support_desk_migrator; diff --git a/applications/support-desk/sql/grants.sql b/applications/support-desk/sql/grants.sql new file mode 100644 index 00000000..33fccf39 --- /dev/null +++ b/applications/support-desk/sql/grants.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP on +GRANT SELECT, INSERT, UPDATE ON support_desk.users, support_desk.tickets TO support_desk_app; +GRANT SELECT, INSERT ON support_desk.replies TO support_desk_app; +GRANT SELECT ON support_desk.schema_migrations, support_desk.ar_internal_metadata TO support_desk_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA support_desk TO support_desk_app; +-- Runtime replies are append-only; reapply after migrations add objects. diff --git a/applications/support-desk/test/acceptance.rb b/applications/support-desk/test/acceptance.rb new file mode 100644 index 00000000..342ca632 --- /dev/null +++ b/applications/support-desk/test/acceptance.rb @@ -0,0 +1,224 @@ +# Run through bin/rails runner against a dedicated migrated Cloud service. +require "minitest/autorun" +require "net/http" +require "securerandom" +require "socket" +require "pg" + +class HttpSession + attr_reader :token + + def initialize + @cookie = nil + @token = nil + end + + def request(method, path, data = {}, csrf: true) + uri = URI("http://127.0.0.1:3000#{path}") + request = Net::HTTP.const_get(method.capitalize).new(uri) + request["Cookie"] = @cookie if @cookie + if method != "get" + request.set_form_data(data.merge(csrf ? { "authenticity_token" => @token } : {})) + end + response = Net::HTTP.start(uri.hostname, uri.port, read_timeout: 20) { |http| http.request(request) } + @cookie = response["Set-Cookie"].split(";").first if response["Set-Cookie"] + @token = response.body[/name="csrf-token" content="([^"]+)"/, 1] || @token + response + end + + def login(user, password) + request("get", "/session/new") + response = request("post", "/session", { "email" => user.email, "password" => password }) + raise "Sign in failed: #{response.code}" unless response.code == "303" + request("get", "/tickets") + self + end +end + +module Fixtures + PREFIX = "acceptance-#{SecureRandom.hex(6)}" + PASSWORD = "FixturePass-#{SecureRandom.hex(12)}" + USERS = ["customer", "customer", "staff", "staff"].each_with_index.map do |role, index| + User.create!(name: "Fixture #{index}", email: "#{PREFIX}-#{index}@example.test", role: role, password: PASSWORD) + end + CLIENTS = USERS.map { |user| HttpSession.new.login(user, PASSWORD) } + + def self.cleanup + ActiveRecord::Base.connection_pool.disconnect! + PG.connect(host: ENV.fetch("PGHOST"), port: ENV.fetch("PGPORT", "5432"), dbname: ENV.fetch("PGDATABASE", "postgres"), + user: "support_desk_migrator", password: ENV.fetch("TEST_MIGRATOR_PASSWORD"), + sslmode: "verify-full", sslrootcert: ENV.fetch("PGSSLROOTCERT")) do |connection| + USERS.each do |user| + connection.exec_params("DELETE FROM support_desk.replies WHERE ticket_id IN (SELECT id FROM support_desk.tickets WHERE customer_id = $1)", [user.id]) + connection.exec_params("DELETE FROM support_desk.tickets WHERE customer_id = $1", [user.id]) + end + USERS.each { |user| connection.exec_params("DELETE FROM support_desk.users WHERE id = $1", [user.id]) } + end + end +end +Minitest.after_run { Fixtures.cleanup } + +class Acceptance < Minitest::Test + def setup + @ticket = Ticket.create!(customer: Fixtures::USERS[0], subject: "#{Fixtures::PREFIX} #{SecureRandom.hex(4)}") + @clients = Fixtures::CLIENTS + end + + def post(index, path, data = {}) + @clients[index].request("post", path, data) + end + + def test_authentication_and_csrf + assert_equal "302", HttpSession.new.request("get", "/tickets").code + assert_equal "422", @clients[0].request("post", "/tickets", { "ticket[subject]" => "CSRF rejected", "body" => "Hello" }, csrf: false).code + assert_equal "404", @clients[0].request("get", "/tickets/#{@ticket.id}/claim").code + end + + def test_customer_identity_and_atomic_creation + response = post(0, "/tickets", { "ticket[subject]" => "#{Fixtures::PREFIX} created", "ticket[customer_id]" => Fixtures::USERS[1].id, "body" => "First message" }) + assert_equal "303", response.code + ticket = Ticket.order(:id).last + assert_equal Fixtures::USERS[0].id, ticket.customer_id + assert_equal Fixtures::USERS[0].id, ticket.replies.first.author_id + before = Ticket.count + assert_equal "422", post(0, "/tickets", { "ticket[subject]" => "Rollback", "body" => " " }).code + assert_equal before, Ticket.count + end + + def test_validation_and_cross_customer_access + assert_equal "422", post(0, "/tickets", { "ticket[subject]" => " ", "body" => "Message" }).code + assert_equal "404", @clients[1].request("get", "/tickets/#{@ticket.id}").code + assert_equal "404", post(1, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Intrusion" }).code + assert_equal "404", @clients[0].request("get", "/tickets/not-an-id").code + refute_includes @clients[1].request("get", "/tickets").body, @ticket.subject + end + + def test_competing_claims + ready = Queue.new + start = Queue.new + threads = [2, 3].map do |index| + Thread.new { ready << true; start.pop; post(index, "/tickets/#{@ticket.id}/claim").code } + end + 2.times { ready.pop } + 2.times { start << true } + assert_equal ["303", "409"], threads.map(&:value).sort + assert_includes Fixtures::USERS[2..3].map(&:id), @ticket.reload.assignee_id + end + + def test_repeat_claim_and_staff_permissions + assert_equal "403", post(0, "/tickets/#{@ticket.id}/claim").code + assert_equal "403", post(2, "/tickets", { "ticket[subject]" => "Staff ticket", "body" => "Hello" }).code + assert_equal "303", post(2, "/tickets/#{@ticket.id}/claim").code + assert_equal "303", post(2, "/tickets/#{@ticket.id}/claim").code + assert_equal "409", post(3, "/tickets/#{@ticket.id}/claim").code + assert_equal "403", post(3, "/tickets/#{@ticket.id}/close").code + assert_equal "403", post(3, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Wrong owner" }).code + assert_equal Fixtures::USERS[2].id, @ticket.reload.assignee_id + end + + def test_replies_and_closed_semantics + assert_equal "403", post(2, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Unclaimed" }).code + post(2, "/tickets/#{@ticket.id}/claim") + assert_equal "303", post(0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Customer question", "reply[author_id]" => Fixtures::USERS[1].id }).code + assert_equal Fixtures::USERS[0].id, @ticket.replies.last.author_id + assert_equal "303", post(2, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Staff answer" }).code + assert_equal "303", post(2, "/tickets/#{@ticket.id}/close").code + closed_at = @ticket.reload.closed_at + assert_equal "303", post(2, "/tickets/#{@ticket.id}/close").code + assert_equal closed_at, @ticket.reload.closed_at + assert_equal "409", post(0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Closed attempt" }).code + assert_equal "409", post(2, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Staff closed attempt" }).code + assert_equal 2, @ticket.replies.count + assert_equal "303", post(2, "/tickets/#{@ticket.id}/reopen").code + assert_nil @ticket.reload.closed_at + assert_equal "303", post(0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "After reopen" }).code + end + + def test_close_and_reply_share_the_ticket_lock + post(2, "/tickets/#{@ticket.id}/claim") + ready = Queue.new + start = Queue.new + threads = nil + ActiveRecord::Base.transaction do + @ticket.lock! + operations = [[0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "Racing closure" }], + [2, "/tickets/#{@ticket.id}/close", {}]] + threads = operations.map do |index, path, data| + Thread.new { ready << true; start.pop; post(index, path, data).code } + end + 2.times { ready.pop } + 2.times { start << true } + sleep 0.5 + assert threads.all?(&:alive?), "both HTTP mutations must wait for the parent ticket lock" + end + reply_status, close_status = threads.map(&:value) + assert_equal "303", close_status + assert_includes ["303", "409"], reply_status + assert @ticket.reload.closed? + if reply_status == "303" + assert_equal 1, @ticket.replies.count + assert_operator @ticket.replies.first.created_at, :<=, @ticket.closed_at + else + assert_equal 0, @ticket.replies.count + end + assert_equal "409", post(0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "After closure" }).code + end + + def test_reply_validation_and_deterministic_order + assert_equal "422", post(0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => " " }).code + assert_equal "422", post(0, "/tickets/#{@ticket.id}/replies", { "reply[body]" => "a" * 4001 }).code + timestamp = Time.current + replies = ["First same-time reply", "Second same-time reply"].map { |body| Reply.create!(ticket: @ticket, author: Fixtures::USERS[0], body: body, created_at: timestamp) } + assert_equal replies.map(&:id), @ticket.replies.map(&:id) + html = @clients[0].request("get", "/tickets/#{@ticket.id}").body + assert_operator html.index(replies.first.body), :<, html.index(replies.last.body) + end + + def test_bounded_ticket_and_reply_pages + 22.times { |n| Ticket.create!(customer: Fixtures::USERS[0], subject: "#{Fixtures::PREFIX} page #{n}") } + html = @clients[0].request("get", "/tickets").body + assert_equal 20, html.scan(/class="ticket-row"/).length + assert_includes html, "Older tickets" + assert_equal "400", @clients[0].request("get", "/tickets?page=0").code + timestamp = Time.current + 51.times { |n| Reply.create!(ticket: @ticket, author: Fixtures::USERS[0], body: "Paged reply #{n}", created_at: timestamp) } + latest = @clients[0].request("get", "/tickets/#{@ticket.id}").body + assert_equal 50, latest.scan(/class="reply"/).length + assert_includes latest, "Paged reply 50" + refute_includes latest, ">Paged reply 0<" + assert_includes latest, "Older replies" + older = @clients[0].request("get", "/tickets/#{@ticket.id}?reply_page=2").body + assert_equal 1, older.scan(/class="reply"/).length + assert_includes older, "Paged reply 0" + assert_equal "400", @clients[0].request("get", "/tickets/#{@ticket.id}?reply_page=bad").code + end + + def test_runtime_permissions + connection = ActiveRecord::Base.connection + assert_raises(ActiveRecord::StatementInvalid) { connection.execute("CREATE TABLE support_desk.must_not_exist (id integer)") } + assert_raises(ActiveRecord::StatementInvalid) { connection.execute("UPDATE support_desk.schema_migrations SET version = version") } + assert_raises(ActiveRecord::StatementInvalid) { connection.execute("UPDATE support_desk.replies SET body = body") } + assert_raises(ActiveRecord::StatementInvalid) { connection.execute("DELETE FROM support_desk.replies WHERE false") } + end + + def test_database_constraints + assert_raises(ActiveRecord::StatementInvalid) do + ActiveRecord::Base.connection.exec_query("UPDATE tickets SET status = 'closed', closed_at = NULL WHERE id = #{@ticket.id}") + end + assert_raises(ActiveRecord::StatementInvalid) do + Reply.insert_all!([{ ticket_id: @ticket.id, author_id: Fixtures::USERS[0].id, body: " ", created_at: Time.current }]) + end + end + + def test_verified_tls_and_negative_controls + assert ActiveRecord::Base.connection.select_value("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()") + params = { host: ENV.fetch("PGHOST"), port: ENV.fetch("PGPORT", "5432"), dbname: ENV.fetch("PGDATABASE", "postgres"), + user: ENV.fetch("PGUSER"), password: ENV.fetch("PGPASSWORD"), sslmode: "verify-full", connect_timeout: 10 } + error = assert_raises(PG::ConnectionBad) { PG.connect(**params, sslrootcert: "/etc/ssl/certs/ca-certificates.crt") } + assert_includes error.message.downcase, "certificate verify failed" + params[:hostaddr] = IPSocket.getaddress(params[:host]) + params[:host] = "wrong-hostname.example.invalid" + error = assert_raises(PG::ConnectionBad) { PG.connect(**params, sslrootcert: ENV.fetch("PGSSLROOTCERT")) } + assert_includes error.message.downcase, "does not match host name" + end +end diff --git a/applications/support-desk/test/browser.py b/applications/support-desk/test/browser.py new file mode 100644 index 00000000..a2bb1b73 --- /dev/null +++ b/applications/support-desk/test/browser.py @@ -0,0 +1,55 @@ +import os +from pathlib import Path +from playwright.sync_api import sync_playwright, expect + +PASSWORD = os.environ["DEMO_PASSWORD"] +expect.set_options(timeout=20000) +with sync_playwright() as p: + browser = p.chromium.launch() + contexts = [browser.new_context(viewport={"width": 1440, "height": 1100}) for _ in range(2)] + customer, staff = [context.new_page() for context in contexts] + errors = [] + for page, email in [(customer, "alex@example.test"), (staff, "morgan@example.test")]: + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto("http://127.0.0.1:3000/session/new") + page.get_by_label("Email").fill(email) + page.get_by_label("Password").fill(PASSWORD) + page.get_by_role("button", name="Sign in").click() + expect(page.get_by_role("heading", name="Your tickets" if page == customer else "All tickets")).to_be_visible() + page.wait_for_function("typeof Turbo !== 'undefined'") + expect(page.locator("body")).to_have_css("background-color", "rgb(246, 245, 242)") + customer.get_by_role("link", name="New ticket").click() + customer.get_by_label("Subject").fill("Browser: help with a connection") + customer.get_by_label("Message", exact=True).fill("I need help setting up a connection.") + customer.get_by_role("button", name="Create ticket").click() + expect(customer.get_by_role("heading", name="Browser: help with a connection")).to_be_visible() + ticket_url = customer.url + staff.goto(ticket_url) + staff.get_by_role("button", name="Claim ticket").click() + expect(staff.get_by_text("You're the owner of this conversation.")).to_be_visible() + staff.locator("form.reply-form").evaluate("form => form.noValidate = true") + staff.get_by_label("Your reply").fill(" ") + staff.get_by_role("button", name="Add reply").click() + expect(staff.get_by_role("alert")).to_contain_text("Body can't be blank") + staff.get_by_label("Your reply").fill("Please use the direct hostname and downloaded CA.") + staff.get_by_role("button", name="Add reply").click() + expect(staff.get_by_text("Please use the direct hostname and downloaded CA.")).to_be_visible() + staff.get_by_role("button", name="Close ticket").click() + expect(staff.get_by_text("This ticket is closed. Staff must reopen it before anyone can reply.")).to_be_visible() + customer.reload() + expect(customer.get_by_text("This ticket is closed. Staff must reopen it before anyone can reply.")).to_be_visible() + staff.get_by_role("button", name="Reopen ticket").click() + expect(staff.get_by_role("button", name="Close ticket")).to_be_visible() + customer.reload() + expect(customer.get_by_label("Your reply")).to_be_visible() + customer.get_by_label("Your reply").fill("That worked, thank you.") + customer.get_by_role("button", name="Add reply").click() + expect(customer.get_by_text("That worked, thank you.")).to_be_visible() + Path(".deployment").mkdir(exist_ok=True) + staff.screenshot(path=".deployment/staff-desktop.png", full_page=True) + customer.set_viewport_size({"width": 390, "height": 844}) + customer.screenshot(path=".deployment/customer-mobile.png", full_page=True) + assert customer.evaluate("document.documentElement.scrollWidth <= window.innerWidth"), "mobile overflow" + assert not errors, errors + print("Browser customer/staff login, ticket create, Turbo claim/reply/status, 422 rendering and responsive layout passed.") + browser.close() diff --git a/applications/support-desk/test/persistence.py b/applications/support-desk/test/persistence.py new file mode 100644 index 00000000..3260d163 --- /dev/null +++ b/applications/support-desk/test/persistence.py @@ -0,0 +1,40 @@ +import json +import os +import re +import sys +from pathlib import Path +import requests + +state_file = Path('.deployment/persistence.json') +base = 'http://127.0.0.1:3000' +session = requests.Session() + +def token(response): + return re.search(r'name="csrf-token" content="([^"]+)"', response.text).group(1) + +if sys.argv[1] == 'before': + page = session.get(base + '/session/new', timeout=20) + response = session.post(base + '/session', data={'email': 'alex@example.test', + 'password': os.environ['DEMO_PASSWORD'], 'authenticity_token': token(page)}, + allow_redirects=False, timeout=20) + assert response.status_code == 303 + page = session.get(base + '/tickets/new', timeout=20) + response = session.post(base + '/tickets', data={'ticket[subject]': 'Persistence: connection help', + 'body': 'This exact initial message must survive a server process restart.', + 'authenticity_token': token(page)}, allow_redirects=False, timeout=20) + assert response.status_code == 303 + state_file.write_text(json.dumps({'cookies': session.cookies.get_dict(), + 'url': response.headers['Location']})) + state_file.chmod(0o600) + print('Before restart: authenticated customer cookie and durable ticket/reply recorded.') +else: + state = json.loads(state_file.read_text()) + for name, value in state['cookies'].items(): + session.cookies.set(name, value, domain='127.0.0.1', path='/') + response = session.get(state['url'], allow_redirects=False, timeout=20) + assert response.status_code == 200 + assert 'Persistence: connection help' in response.text + assert response.text.count('This exact initial message must survive a server process restart.') == 1 + assert 'Alex' in response.text + state_file.unlink() + print('After actual process restart: same encrypted cookie authenticates; exact ticket/reply persist once.') diff --git a/applications/support-desk/test/requirements.txt b/applications/support-desk/test/requirements.txt new file mode 100644 index 00000000..6e5cc5c5 --- /dev/null +++ b/applications/support-desk/test/requirements.txt @@ -0,0 +1,2 @@ +playwright==1.55.0 +requests==2.32.5 From dd2e61d9a2ce754c3b4c591aa4e6cc52219cb0f6 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 15:26:44 +0100 Subject: [PATCH 2/3] Update browser test helper to Playwright 1.56.0 --- applications/support-desk/test/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/support-desk/test/requirements.txt b/applications/support-desk/test/requirements.txt index 6e5cc5c5..28a3e866 100644 --- a/applications/support-desk/test/requirements.txt +++ b/applications/support-desk/test/requirements.txt @@ -1,2 +1,2 @@ -playwright==1.55.0 +playwright==1.56.0 requests==2.32.5 From aa9701ce78072b27a35159b067a272b157559cf0 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 21:31:16 +0100 Subject: [PATCH 3/3] docs: remove public beta wording --- applications/support-desk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/support-desk/README.md b/applications/support-desk/README.md index 0e8c4077..9607f4a8 100644 --- a/applications/support-desk/README.md +++ b/applications/support-desk/README.md @@ -1,6 +1,6 @@ # Support desk -Create a ticket, keep its replies together, and assign one staff owner with **Rails**, **Active Record**, **pg**, and **Hotwire/Turbo** on **ClickHouse Managed Postgres (public beta)**. Customers see and reply to their own tickets. Staff see the queue, claim unassigned tickets, reply to tickets they own, and close or reopen them. +Create a ticket, keep its replies together, and assign one staff owner with **Rails**, **Active Record**, **pg**, and **Hotwire/Turbo** on **ClickHouse Managed Postgres**. Customers see and reply to their own tickets. Staff see the queue, claim unassigned tickets, reply to tickets they own, and close or reopen them. Two staff members can press **Claim ticket** together. The ticket row lock makes the second request observe the first assignment, so exactly one succeeds. The same lock coordinates replies and closure: an accepted reply happens before closure; a closed ticket rejects new replies until its assigned owner reopens it.