diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..0a43fdab
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,35 @@
+FROM openproject/openproject:17-slim
+
+# --- Phase 1: Install system deps needed at build time ---
+USER root
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git \
+ && rm -rf /var/lib/apt/lists/*
+
+# --- Phase 2: Copy custom plugins into the plugins directory ---
+COPY --chown=app:app custom-plugin/openproject-livesolutions /app/plugins/openproject-livesolutions
+COPY --chown=app:app custom-plugin/openproject-request-portal /app/plugins/openproject-request-portal
+
+# --- Phase 3: Register plugins via Gemfile.plugins ---
+RUN printf "gem 'openproject-livesolutions', path: 'plugins/openproject-livesolutions'\ngem 'openproject-request-portal', path: 'plugins/openproject-request-portal'\n" \
+ > /app/Gemfile.plugins \
+ && chown app:app /app/Gemfile.plugins
+
+# --- Phase 4: Bundle install (unlock deployment mode so new gems resolve) ---
+USER root
+RUN cd /app \
+ && sed -i 's/^BUNDLE_DEPLOYMENT:/#BUNDLE_DEPLOYMENT:/' .bundle/config \
+ && chown app:app .bundle/config \
+ && su app -c "bundle install" \
+ && sed -i 's/^#BUNDLE_DEPLOYMENT:/BUNDLE_DEPLOYMENT:/' .bundle/config \
+ && chown app:app .bundle/config
+
+# --- Phase 5: Cleanup git (not needed at runtime) ---
+USER root
+RUN apt-get purge -y --auto-remove git \
+ && rm -rf /var/lib/apt/lists/*
+
+# --- Phase 6: Ensure correct ownership of all app files ---
+RUN chown -R app:app /app
+
+USER app
diff --git a/control/Dockerfile b/control/Dockerfile
index a90a259d..dd62643f 100644
--- a/control/Dockerfile
+++ b/control/Dockerfile
@@ -1,11 +1,13 @@
-FROM debian:12
+FROM postgres:17-bookworm
-RUN apt-get update -qq && apt-get install wget gnupg2 -y && rm -rf /var/lib/apt/lists/*
-RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
-RUN echo "deb http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list
-RUN apt-get update -qq && apt-get install postgresql-9.6 postgresql-10 postgresql-13 -y && rm -rf /var/lib/apt/lists/*
-RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
+# Install rsync and compression tools useful for upgrade/restore, plus any PG
+# client version we might need if we ever have to convert PG 13 -> 17 in-place.
+RUN apt-get update -qq \
+ && apt-get install -y --no-install-recommends \
+ rsync wget ca-certificates locales \
+ && rm -rf /var/lib/apt/lists/*
+# Keep the same locale the upstream Postgres image already sets.
ENV LANG en_US.utf8
ADD . /control
diff --git a/control/backup/entrypoint.sh b/control/backup/entrypoint.sh
index f81abe2b..5323e802 100755
--- a/control/backup/entrypoint.sh
+++ b/control/backup/entrypoint.sh
@@ -1,13 +1,50 @@
#!/bin/bash
+# OpenProject offline backup entrypoint.
+# Creates a point-in-time physical backup of the PostgreSQL data directory and
+# OpenProject assets. This is intended to run when the stack is DOWN, via the
+# control compose file.
+#
+# For a live logical SQL dump (more portable across Postgres major versions),
+# use scripts/backup-logical.sh while the stack is up.
+
set -e
+set -o pipefail
timestamp=$(date +%s)
mkdir -p /backups
cd /backups
-filename="${timestamp}-pgdata.tar.gz"
-echo "Backing up PostgreSQL data into backups/${filename}..."
-tar czf "${filename}" -C "$PGDATA" .
-filename="${timestamp}-opdata.tar.gz"
-echo "Backing up OpenProject assets into backups/${filename}..."
-tar czf "${filename}" -C "$OPDATA" .
-echo "DONE"
+
+# ---------------------------------------------------------------------------
+# 1. Physical PostgreSQL data tarball
+# ---------------------------------------------------------------------------
+pgdata_file="${timestamp}-pgdata.tar.gz"
+echo "Creating physical PostgreSQL data backup: backups/${pgdata_file} ..."
+tar czf "${pgdata_file}" -C "$PGDATA" .
+echo "Physical backup complete: backups/${pgdata_file}"
+
+# ---------------------------------------------------------------------------
+# 2. OpenProject assets tarball
+# ---------------------------------------------------------------------------
+opdata_file="${timestamp}-opdata.tar.gz"
+echo "Creating OpenProject assets backup: backups/${opdata_file} ..."
+tar czf "${opdata_file}" -C "$OPDATA" .
+echo "Assets backup complete: backups/${opdata_file}"
+
+# ---------------------------------------------------------------------------
+# 3. Configuration / customization manifest
+# ---------------------------------------------------------------------------
+manifest_file="${timestamp}-manifest.txt"
+echo "Writing backup manifest: backups/${manifest_file} ..."
+{
+ echo "backup_timestamp=${timestamp}"
+ echo "backup_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo "postgres_version=$(cat "$PGDATA/PG_VERSION" 2>/dev/null || echo unknown)"
+ echo "opdata_size=$(du -sb "$OPDATA" | cut -f1)"
+ echo "pgdata_size=$(du -sb "$PGDATA" | cut -f1)"
+ echo "pgdata_file=${pgdata_file}"
+ echo "opdata_file=${opdata_file}"
+ echo "custom_plugin_commit=$(git -C /control rev-parse --short HEAD 2>/dev/null || echo n/a)"
+} > "${manifest_file}"
+
+echo "DONE: backups/${manifest_file}"
+ls -lh /backups/${timestamp}-*
diff --git a/control/restore/entrypoint.sh b/control/restore/entrypoint.sh
new file mode 100755
index 00000000..f855915c
--- /dev/null
+++ b/control/restore/entrypoint.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# OpenProject restore entrypoint.
+# Restores a previous backup set into the live PGDATA and OPDATA paths.
+# Use this only when the stack is DOWN.
+#
+# Usage:
+# docker compose -f docker-compose.yml -f docker-compose.control.yml \
+# run --rm -e RESTORE_TIMESTAMP=1782964243 restore
+#
+# If RESTORE_TIMESTAMP is omitted, the most recent backup set is used.
+
+set -e
+set -o pipefail
+
+BACKUP_DIR=/backups
+
+timestamp="${RESTORE_TIMESTAMP:-}"
+if [ -z "$timestamp" ]; then
+ timestamp="$(ls -1 "$BACKUP_DIR" | grep -E '^[0-9]+-manifest\.txt$' | sort -n | tail -1 | cut -d- -f1)"
+fi
+
+if [ -z "$timestamp" ]; then
+ echo "ERROR: no backup manifest found in $BACKUP_DIR" >&2
+ exit 1
+fi
+
+echo "Restoring backup set: ${timestamp}"
+
+# ---------------------------------------------------------------------------
+# 1. Restore OpenProject assets
+# ---------------------------------------------------------------------------
+opdata_file="${BACKUP_DIR}/${timestamp}-opdata.tar.gz"
+if [ -f "$opdata_file" ]; then
+ echo "Restoring assets from ${opdata_file} ..."
+ mkdir -p "$OPDATA"
+ rm -rf "${OPDATA:?}"/*
+ tar xzf "$opdata_file" -C "$OPDATA"
+ echo "Assets restored."
+else
+ echo "WARNING: ${opdata_file} not found; skipping asset restore." >&2
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Restore PostgreSQL physical data
+# ---------------------------------------------------------------------------
+pgdata_file="${BACKUP_DIR}/${timestamp}-pgdata.tar.gz"
+if [ -f "$pgdata_file" ]; then
+ echo "Restoring PostgreSQL data from ${pgdata_file} ..."
+ rm -rf "${PGDATA:?}"/*
+ tar xzf "$pgdata_file" -C "$PGDATA"
+ chown -R postgres:postgres "$PGDATA"
+ echo "PostgreSQL data restored."
+else
+ echo "WARNING: ${pgdata_file} not found; skipping PG physical restore." >&2
+fi
+
+# ---------------------------------------------------------------------------
+# 3. Re-apply container network ACLs (in case an old cluster lacked them)
+# ---------------------------------------------------------------------------
+if [ -d "$PGDATA" ] && [ -f "$PGDATA/postgresql.conf" ]; then
+ if ! grep -q "listen_addresses = '\*'" "$PGDATA/postgresql.conf"; then
+ echo "Ensuring listen_addresses='*' is set..."
+ echo "listen_addresses = '*'" >> "$PGDATA/postgresql.conf"
+ fi
+ if ! grep -q "host all all all md5" "$PGDATA/pg_hba.conf"; then
+ echo "Ensuring host all all all md5 is set..."
+ echo "host all all all md5" >> "$PGDATA/pg_hba.conf"
+ fi
+fi
+
+echo "DONE: restore complete for timestamp ${timestamp}."
+echo "Start the stack with: docker compose up -d --build --pull always"
diff --git a/control/upgrade/scripts/00-db-upgrade.sh b/control/upgrade/scripts/00-db-upgrade.sh
index 1e89bade..cb8efdcb 100755
--- a/control/upgrade/scripts/00-db-upgrade.sh
+++ b/control/upgrade/scripts/00-db-upgrade.sh
@@ -1,13 +1,25 @@
#!/bin/bash
+# PostgreSQL major-version upgrade script for the OpenProject control plane.
+# Uses pg_upgrade when the running (old) Postgres major version is lower than
+# the target version supplied by the container image.
+#
+# IMPORTANT: This script is meant to be run via the `upgrade` service defined in
+# docker-compose.control.yml. The stack must be DOWN before running it, because
+# it rewrites the contents of the PGDATA volume.
+
set -e
set -o pipefail
-CURRENT_PGVERSION="$(cat $PGDATA/PG_VERSION)"
-NEW_PGVERSION="13"
+CURRENT_PGVERSION="$(cat "$PGDATA/PG_VERSION")"
+# Use the major version of the postgres binaries baked into this image.
+NEW_PGVERSION="$(pg_ctl --version | sed -E 's/.* ([0-9]+).*/\1/')"
PGWORKDIR=${PGWORKDIR:=/var/lib/postgresql/work}
+echo "Detected current PGDATA version: ${CURRENT_PGVERSION}"
+echo "Control-plane image PG version: ${NEW_PGVERSION}"
+
if [ ! "$CURRENT_PGVERSION" -lt "$NEW_PGVERSION" ]; then
- echo "Current PG version is higher or equal to the PG version to be installed ($CURRENT_PGVERSION > $NEW_PGVERSION). Ignoring."
+ echo "Current PG version is already >= target version (${CURRENT_PGVERSION} >= ${NEW_PGVERSION}). Nothing to do."
exit 0
fi
@@ -16,17 +28,31 @@ export PGBINNEW="/usr/lib/postgresql/$NEW_PGVERSION/bin"
export PGDATAOLD="$PGDATA"
export PGDATANEW="$PGWORKDIR/datanew"
+if [ ! -d "$PGBINOLD" ] || [ ! -x "$PGBINOLD/pg_ctl" ]; then
+ echo "ERROR: old cluster binaries not found at $PGBINOLD" >&2
+ echo "The control image only contains PG ${NEW_PGVERSION}. You must build or use an image that also ships PG ${CURRENT_PGVERSION} binaries." >&2
+ exit 1
+fi
+
rm -rf "$PGWORKDIR" && mkdir -p "$PGWORKDIR" "$PGDATANEW"
-chown -R postgres.postgres "$PGDATA" "$PGWORKDIR"
+chown -R postgres:postgres "$PGDATA" "$PGWORKDIR"
cd "$PGWORKDIR"
-# initialize new db
+
+# initialize new db cluster
+echo "Initializing new PostgreSQL ${NEW_PGVERSION} cluster..."
su -m postgres -c "$PGBINNEW/initdb --pgdata=$PGDATANEW --encoding=unicode --auth=trust"
-echo "Performing a dry-run migration to PostgreSQL $NEW_PGVERSION..."
-su -m postgres -c "$PGBINNEW/pg_upgrade -c"
-echo "Performing the real migration to PostgreSQL $NEW_VERSION..."
-su -m postgres -c "$PGBINNEW/pg_upgrade"
+
+echo "Performing a dry-run migration to PostgreSQL ${NEW_PGVERSION}..."
+su -m postgres -c "$PGBINNEW/pg_upgrade --old-datadir=$PGDATAOLD --new-datadir=$PGDATANEW --old-bindir=$PGBINOLD --new-bindir=$PGBINNEW -c"
+
+echo "Performing the real migration to PostgreSQL ${NEW_PGVERSION}..."
+su -m postgres -c "$PGBINNEW/pg_upgrade --old-datadir=$PGDATAOLD --new-datadir=$PGDATANEW --old-bindir=$PGBINOLD --new-bindir=$PGBINNEW"
+
+echo "Replacing old cluster data with upgraded cluster..."
su -m postgres -c "rm -rf $PGDATAOLD/* && mv $PGDATANEW/* $PGDATAOLD/"
-# as per docker hub documentation
+
+# as per docker hub documentation, ensure remote container access still works
su -m postgres -c "echo \"listen_addresses = '*'\" >> $PGDATAOLD/postgresql.conf"
su -m postgres -c "echo \"host all all all md5\" >> $PGDATAOLD/pg_hba.conf"
+
echo "DONE"
diff --git a/custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js b/custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js
new file mode 100644
index 00000000..b80e344f
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js
@@ -0,0 +1,802 @@
+/*
+ * Live Solutions hierarchy "Collapse all" / "Expand all" runtime patch.
+ *
+ * Background:
+ * OpenProject 17 ships the compiled Angular frontend inside the container
+ * image. We cannot rebuild the bundle, so this file is loaded as a plain
+ *
+
+<%#--
+ Request #777 — work-package hierarchy "Collapse all" / "Expand all".
+ Same loading pattern as Request #778 above (live-mounted JS, no image
+ rebuild). The script is idempotent and self-observes the DOM so it
+ works whether Angular has finished bootstrapping or not.
+
+ Persistence note (2026-08-25):
+ The collapse/expand state is now persisted in the backend, not in
+ localStorage. The JS calls /api/v3/livesolutions_hierarchy_state to
+ read the user's saved state on page load and write it after bulk or
+ individual toggles. Existing and future users receive a default row
+ (all_collapsed=true) from the migration and User model patch, making
+ the feature always-on and permanent across browsers/devices.
+
+ CSP note: script-src 'self' (plus js.chargebee.com) per
+ config/initializers/content_security_policy.rb covers same-origin
+ /javascripts/* URLs without a nonce, mirroring pagination-default.
+--%>
+
diff --git a/custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb b/custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb
new file mode 100644
index 00000000..b48bd4d3
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb
@@ -0,0 +1,27 @@
+<%#--
+ Live Solutions branding override for OpenProject logos.
+ Replaces the default OpenProject wordmark and icon with Live Solutions assets.
+ Assets are served from /livesolutions-branding/ (mounted public path) to bypass
+ the fingerprinted asset pipeline.
+--%>
+
+
diff --git a/custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb b/custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb
new file mode 100644
index 00000000..02cafd34
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb
@@ -0,0 +1,21 @@
+<%#--
+ Live Solutions homescreen override.
+ Replaces the organization-name text header with the LS logo.
+--%>
+
+<%=
+ render(Primer::OpenProject::PageHeader.new) do |header|
+ header.with_title do
+ content_tag(:img, "", src: "/livesolutions-branding/logo_openproject.png", alt: organization_name, style: "height: 60px; width: auto;")
+ end
+ header.with_breadcrumbs(nil)
+ end
+%>
+
+<%= render Homescreen::AnnouncementComponent.new(announcement: @announcement) %>
+
+<%= render Homescreen::BlocksGridComponent.new(homescreen: @homescreen) %>
+
+<%= render Homescreen::LinksComponent.new(homescreen: @homescreen) %>
+
+<%= call_hook :homescreen_after_links %>
diff --git a/custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb b/custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb
new file mode 100644
index 00000000..20a4a869
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb
@@ -0,0 +1,65 @@
+<%
+ # Live Solutions patch for OpenProject 17.6.0 Community Edition.
+ # Root cause: when duplicating work packages across projects, a validation
+ # failure leaves the copy result as an unpersisted WorkPackage with id=nil.
+ # The upstream _errors.html.erb partial unconditionally calls
+ # `work_package_path(wp)` on that unpersisted record, which raises
+ # ActionView::Template::Error because the `work_packages#show` route requires
+ # a non-nil :id constraint. This turns a user-fixable validation error into
+ # a generic 500 page.
+ #
+ # Fix: only render a link to the work package when it has been persisted
+ # (wp.id present). Otherwise display the formatted/source ID as plain text.
+ # The source work package (when available) is still shown so the user knows
+ # which record failed.
+ erroneous_results = service_result.results_with_errors(include_self: false)
+ error_count = erroneous_results.count
+ total_count = service_result.dependent_results.map(&:result).uniq.count
+ selected_count = selected_work_packages.count
+ selected_ids = selected_work_packages.map(&:id)
+ source_ids = erroneous_results.map { |call| call.state.copied_from_work_package_id }.compact.uniq
+ source_work_packages_by_id = source_ids.any? ? WorkPackage.visible.where_display_id_in(*source_ids).index_by(&:id) : {}
+%>
+
+<% if total_count - error_count == 0 %>
+ <%= t(
+ "work_packages.bulk.none_could_be_saved",
+ total: total_count
+ ) %>
+<% else %>
+ <%= t(
+ "work_packages.bulk.x_out_of_y_could_be_saved",
+ failing: error_count,
+ total: total_count,
+ success: total_count - error_count
+ ) %>
+
+ <%= t("work_packages.bulk.could_not_be_saved") %>
+<% end %>
+
+<% if selected_count < total_count %>
+ <%= t(
+ "work_packages.bulk.selected_because_descendants",
+ total: total_count,
+ selected: selected_count
+ ) %>
+<% end %>
+
+
+ <% erroneous_results.each do |call| %>
+ <% source_id = call.state.copied_from_work_package_id %>
+ <% wp = source_id ? source_work_packages_by_id[source_id] : call.result %>
+ <% wp_id = wp&.id || source_id || call.result&.id %>
+
+ -
+ <% if wp&.persisted? %>
+ <%= link_to wp.formatted_id || "##{wp_id}", work_package_path(wp) %><%= selected_ids.include?(wp_id) ? "" : " (#{I18n.t('work_packages.bulk.descendant')})" %>:
+ <% else %>
+
+ <%= wp&.formatted_id || "##{wp_id}" %><%= selected_ids.include?(wp_id) ? "" : " (#{I18n.t('work_packages.bulk.descendant')})" %>:
+
+ <% end %>
+ <%= safe_join call.errors.full_messages, " " %>
+
+ <% end %>
+
diff --git a/custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb b/custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb
new file mode 100644
index 00000000..8e328900
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb
@@ -0,0 +1,61 @@
+<%#-- copyright
+OpenProject is an open source project management software.
+Copyright (C) the OpenProject GmbH
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<% html_title(t('activerecord.attributes.project.work_packages')) -%>
+<%= call_hook(:view_work_packages_index_bottom, { project: project, query: query }) %>
+
+<% content_for :sidebar do %>
+ <%= render partial: 'sidebar' %>
+<% end %>
+
+<% content_for :header_tags do %>
+ <%= auto_discovery_link_tag(:atom, {query_id: query, format: 'atom', page: nil, key: User.current.rss_key}, title: t(:label_work_package_plural)) %>
+ <%= auto_discovery_link_tag(:atom, {controller: '/journals', action: 'index', query_id: query, format: 'atom', page: nil, key: User.current.rss_key}, title: t(:label_changes_details)) %>
+
+ <%#--
+ Live Solutions bootstrap for request #777.
+
+ We inject the user's persisted hierarchy collapse state as a single JSON
+ meta tag in the page head. The external hierarchy-collapse-all.js script
+ reads this meta tag at load time and restores the collapsed state before
+ Angular finishes painting the work-package table rows. This eliminates the
+ extra HTTP round-trip to /api/v3/livesolutions_hierarchy_state on the
+ initial page load and avoids the visible "flash" of rows expanding then
+ collapsing.
+
+ Using a meta tag keeps the page CSP-safe: no inline script is executed,
+ and the JSON payload is HTML-escaped for use in an attribute.
+ --%>
+ <% if User.current.logged? %>
+ <% state = UserHierarchyCollapseState.find_by(user_id: User.current.id) %>
+ <% if state %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/custom-plugin/openproject-livesolutions/config/locales/en.yml b/custom-plugin/openproject-livesolutions/config/locales/en.yml
new file mode 100644
index 00000000..faaf97b8
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/config/locales/en.yml
@@ -0,0 +1,2 @@
+:default:
+ :openproject_livesolutions: 'Live Solutions brand plugin'
diff --git a/custom-plugin/openproject-livesolutions/db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb b/custom-plugin/openproject-livesolutions/db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb
new file mode 100644
index 00000000..a016db2d
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# This migration creates the per-user hierarchy collapse state table used by
+# issue #777. It stores the user's last hierarchy bulk action (all collapsed
+# or all expanded) and the set of individually collapsed work package ids so
+# the state is persisted in the backend, applies across browsers/devices, and
+# is automatically present for all existing and future users.
+#--
+
+class CreateUserHierarchyCollapseStates < ActiveRecord::Migration[7.1]
+ def up
+ create_table :user_hierarchy_collapse_states do |t|
+ t.references :user, null: false, foreign_key: true, index: { unique: true }
+ t.jsonb :collapsed_ids, null: false, default: []
+ t.boolean :all_collapsed, null: false, default: true
+ t.timestamps
+ end
+
+ # Seed every existing user with the default "all collapsed" state.
+ # Users created after this migration are handled by the User model patch.
+ execute <<~SQL.squish
+ INSERT INTO user_hierarchy_collapse_states (user_id, collapsed_ids, all_collapsed, created_at, updated_at)
+ SELECT id, '[]'::jsonb, true, NOW(), NOW()
+ FROM users
+ WHERE type = 'User'
+ ON CONFLICT (user_id) DO NOTHING
+ SQL
+ end
+
+ def down
+ drop_table :user_hierarchy_collapse_states
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/api/v3/livesolutions/hierarchy_states_api.rb b/custom-plugin/openproject-livesolutions/lib/api/v3/livesolutions/hierarchy_states_api.rb
new file mode 100644
index 00000000..ca981b8c
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/api/v3/livesolutions/hierarchy_states_api.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# API endpoints used by the frontend hierarchy collapse-all/expand-all patch
+# to read and write the per-user backend state. Mounted into API::V3::Root by
+# the engine patch.
+#--
+
+module API
+ module V3
+ module Livesolutions
+ class HierarchyStatesAPI < ::API::OpenProjectAPI
+ resource :livesolutions_hierarchy_state do
+ after_validation do
+ authorize_by_with_raise(current_user.logged?)
+ end
+
+ helpers do
+ def hierarchy_state
+ @hierarchy_state ||= UserHierarchyCollapseState.find_or_initialize_by(user: current_user)
+ end
+
+ def normalize_collapsed_ids(ids)
+ Array(ids).map { |id| id.to_i }.select { |id| id > 0 }.uniq
+ end
+ end
+
+ get do
+ state = hierarchy_state
+ {
+ all_collapsed: state.all_collapsed,
+ collapsed_ids: state.collapsed_ids
+ }
+ end
+
+ params do
+ requires :all_collapsed, type: Boolean
+ optional :collapsed_ids, type: Array[Integer]
+ end
+ post do
+ state = hierarchy_state
+ state.all_collapsed = declared_params[:all_collapsed]
+ state.collapsed_ids = normalize_collapsed_ids(declared_params[:collapsed_ids])
+ if state.save
+ {
+ all_collapsed: state.all_collapsed,
+ collapsed_ids: state.collapsed_ids
+ }
+ else
+ raise ::API::Errors::InvalidRequestBody.new(state.errors.full_messages.join(", "))
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions.rb
new file mode 100644
index 00000000..bceb275d
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions.rb
@@ -0,0 +1,4 @@
+module OpenProject
+ module Livesolutions
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/engine.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/engine.rb
new file mode 100644
index 00000000..2e6edd9d
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/engine.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+# CSS and JS are injected via mounted view overrides and docker-compose volume
+# mount; the legacy hook listener is removed to avoid an OpenProject::Hook
+# load-order dependency that breaks plugin boot in OpenProject 17.
+
+module OpenProject::Livesolutions
+ class Engine < ::Rails::Engine
+ engine_name :openproject_livesolutions
+
+ initializer 'openproject_livesolutions.assets' do |app|
+ app.config.assets.paths << root.join('app', 'assets', 'stylesheets').to_s
+ app.config.assets.paths << root.join('app', 'assets', 'javascripts').to_s
+ app.config.assets.precompile += %w[livesolutions/theme.css]
+ end
+
+ initializer 'openproject_livesolutions.append_migrations' do |app|
+ # Register the plugin's db/migrate directory with the main application
+ # so `rails db:migrate` picks up our custom migrations.
+ app.config.paths['db/migrate'] << root.join('db', 'migrate').to_s
+ end
+
+ config.to_prepare do
+ require 'open_project/livesolutions/patches'
+
+ unless Accounts::CurrentUser.ancestors.include?(OpenProject::Livesolutions::Patches::CurrentUserCloudflarePatch)
+ Accounts::CurrentUser.prepend OpenProject::Livesolutions::Patches::CurrentUserCloudflarePatch
+ end
+
+ unless WorkPackages::Shared::AllDays.ancestors.include?(OpenProject::Livesolutions::Patches::AllDaysLagPatch)
+ WorkPackages::Shared::AllDays.prepend OpenProject::Livesolutions::Patches::AllDaysLagPatch
+ end
+
+ unless Relation.ancestors.include?(OpenProject::Livesolutions::Patches::RelationLagPatch)
+ Relation.prepend OpenProject::Livesolutions::Patches::RelationLagPatch
+ end
+
+ unless API::Decorators::QueryParamsRepresenter.ancestors.include?(OpenProject::Livesolutions::Patches::QueryParamsRepresenterPatch)
+ API::Decorators::QueryParamsRepresenter.prepend OpenProject::Livesolutions::Patches::QueryParamsRepresenterPatch
+ end
+
+ unless UserPreference.ancestors.include?(OpenProject::Livesolutions::Patches::UserPreferencePatch)
+ UserPreference.prepend OpenProject::Livesolutions::Patches::UserPreferencePatch
+ end
+
+ unless User.ancestors.include?(OpenProject::Livesolutions::Patches::UserPatch)
+ User.include OpenProject::Livesolutions::Patches::UserPatch
+ end
+ end
+
+ config.after_initialize do
+ # Mount the Livesolutions API endpoints into API::V3::Root once the core
+ # API classes are loaded and before the route set is frozen. We force-load
+ # the API class here because the plugin lib/ path is not in Rails'
+ # autoload paths by default.
+ begin
+ require 'api/v3/livesolutions/hierarchy_states_api'
+ rescue LoadError => e
+ Rails.logger.error "[LiveSolutions] unable to load HierarchyStatesAPI: #{e.message}"
+ end
+
+ if defined?(::API::V3::Root) && defined?(::API::V3::Livesolutions::HierarchyStatesAPI) &&
+ !::API::V3::Root.instance_variable_defined?(:@livesolutions_api_mounted)
+ ::API::V3::Root.class_eval do
+ mount ::API::V3::Livesolutions::HierarchyStatesAPI
+ end
+ ::API::V3::Root.instance_variable_set(:@livesolutions_api_mounted, true)
+ end
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches.rb
new file mode 100644
index 00000000..b43255dc
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# Require all monkey-patches used by this plugin.
+#--
+
+require 'open_project/livesolutions/patches/all_days_lag_patch'
+require 'open_project/livesolutions/patches/current_user_cloudflare_patch'
+require 'open_project/livesolutions/patches/query_params_representer_patch'
+require 'open_project/livesolutions/patches/relation_lag_patch'
+require 'open_project/livesolutions/patches/user_preference_patch'
+require 'open_project/livesolutions/patches/user_patch'
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/all_days_lag_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/all_days_lag_patch.rb
new file mode 100644
index 00000000..b4190c3b
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/all_days_lag_patch.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# Makes lag respect a successor work package's ignore_non_working_days flag.
+# When a successor is set to ignore non-working days (i.e. "Working days only"
+# is unchecked), lag is interpreted as calendar days instead of working days.
+#--
+
+module OpenProject::Livesolutions::Patches
+ module AllDaysLagPatch
+ def lag(predecessor_date, successor_date)
+ return nil unless predecessor_date && successor_date
+
+ (successor_date - predecessor_date - 1).to_i
+ end
+
+ def with_lag(date, lag)
+ return nil unless date
+
+ date + (lag || 0).days + 1.day
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb
new file mode 100644
index 00000000..36f7ae97
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module OpenProject::Livesolutions::Patches
+ module CurrentUserCloudflarePatch
+ def find_current_user
+ user = current_cloudflare_user
+ return user if user&.logged? && user&.active?
+
+ super
+ end
+
+ private
+
+ def current_cloudflare_user
+ return nil unless cloudflare_access_enabled?
+
+ email = request.headers['Cf-Access-Authenticated-User-Email'].presence
+ return nil if email.blank?
+
+ email = email.downcase.strip
+
+ user = User.active.find_by(mail: email) || create_cloudflare_user(email)
+ return nil unless user&.active?
+
+ # Establish a session for this user.
+ login_user(user)
+ user
+ rescue StandardError => e
+ Rails.logger.error "[CloudflareAccessAuth] authentication failed for #{email}: #{e.message}"
+ Rails.logger.error e.backtrace.first(5).join("\n")
+ nil
+ end
+
+ def create_cloudflare_user(email)
+ name = request.headers['Cf-Access-Authenticated-User-Name'].presence || email.split('@').first
+ firstname, lastname = split_name(name)
+ login = unique_login_from_email(email)
+ password = SecureRandom.hex(32)
+
+ user = User.new(
+ mail: email,
+ login: login,
+ firstname: firstname,
+ lastname: lastname,
+ status: User.statuses[:active],
+ admin: false,
+ password: password,
+ password_confirmation: password
+ )
+
+ # SSO users do not need local password validation.
+ user.save(validate: false)
+ user
+ end
+
+ def split_name(name)
+ parts = name.to_s.strip.split
+ if parts.length > 1
+ [parts.first, parts[1..-1].join(' ')]
+ else
+ [parts.first || 'Live', 'Solutions']
+ end
+ end
+
+ def unique_login_from_email(email)
+ local = email.split('@').first.to_s.downcase
+ local = local.gsub(/[^a-z0-9_\-.]/, '_')[0, 60]
+ return email.gsub(/[^a-z0-9_\-.@]/, '_')[0, 60] if local.blank?
+
+ base = local
+ counter = 0
+ while User.exists?(login: base)
+ counter += 1
+ suffix = "_#{counter}"
+ base = "#{local[0, 60 - suffix.length]}#{suffix}"
+ end
+ base
+ end
+
+ def cloudflare_access_enabled?
+ ENV.fetch('TRUST_CF_ACCESS_EMAIL', 'false').to_s == 'true'
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/query_params_representer_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/query_params_representer_patch.rb
new file mode 100644
index 00000000..74f5deae
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/query_params_representer_patch.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# Forces the default work-package collection page size to 100 for every user
+# and every query (#778). The default was 20 because
+# Setting.per_page_options_array.first == 20. This patch changes the API
+# default hash so fresh requests, exported views, and embedded queries all
+# use 100 unless the user explicitly overrides it.
+#--
+
+module OpenProject::Livesolutions::Patches
+ module QueryParamsRepresenterPatch
+ def default_hash
+ super.merge(pageSize: 100)
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/relation_lag_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/relation_lag_patch.rb
new file mode 100644
index 00000000..79195ef8
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/relation_lag_patch.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# Uses the successor work package's day computation mode when calculating the
+# earliest start date imposed by a follows relation. When the successor ignores
+# non-working days, lag is interpreted in calendar days; otherwise it is
+# interpreted in working days.
+#--
+
+module OpenProject::Livesolutions::Patches
+ module RelationLagPatch
+ def successor_soonest_start
+ if follows? && predecessor_date
+ days = WorkPackages::Shared::Days.for(successor)
+ days.with_lag(predecessor_date, lag)
+ end
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_patch.rb
new file mode 100644
index 00000000..37c01584
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_patch.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# Ensures every user (existing and future) has a UserHierarchyCollapseState
+# row with the default "all collapsed" state. New users get the row created
+# automatically on user creation via after_create.
+#--
+
+module OpenProject::Livesolutions::Patches
+ module UserPatch
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :user_hierarchy_collapse_state, dependent: :destroy
+ after_create :livesolutions_ensure_hierarchy_collapse_state
+ end
+
+ private
+
+ def livesolutions_ensure_hierarchy_collapse_state
+ UserHierarchyCollapseState.find_or_create_by(user: self) do |state|
+ state.all_collapsed = true
+ state.collapsed_ids = []
+ end
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_preference_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_preference_patch.rb
new file mode 100644
index 00000000..1fbf3722
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_preference_patch.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+#-- copyright
+# Live Solutions customization for OpenProject.
+#
+# Ensures the per-page preference defaults to 100 and the global hierarchy
+# collapse preference defaults to true for every user. This applies to new
+# preferences as well as existing ones when the settings are missing.
+#
+# Important: we do not mutate the serialized hash returned by ActiveRecord;
+# we return a duplicate with the defaults applied, so the record is only
+# marked dirty when the caller actually changes something.
+#--
+
+module OpenProject::Livesolutions::Patches
+ module UserPreferencePatch
+ def settings
+ value = super
+ value = {} if value.blank?
+
+ with_defaults = value.dup
+ with_defaults[:per_page] = 100 if with_defaults[:per_page].blank?
+ with_defaults[:hierarchy_collapsed_all] = true if with_defaults[:hierarchy_collapsed_all].nil?
+ with_defaults
+ end
+
+ def settings=(value)
+ value = {} if value.blank?
+ value[:per_page] = 100 if value[:per_page].blank?
+ value[:hierarchy_collapsed_all] = true if value[:hierarchy_collapsed_all].nil?
+ super(value)
+ end
+ end
+end
diff --git a/custom-plugin/openproject-livesolutions/lib/openproject-livesolutions.rb b/custom-plugin/openproject-livesolutions/lib/openproject-livesolutions.rb
new file mode 100644
index 00000000..87cd3d2e
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/lib/openproject-livesolutions.rb
@@ -0,0 +1,3 @@
+require 'open_project/livesolutions'
+require 'open_project/livesolutions/engine'
+require 'open_project/livesolutions/patches'
diff --git a/custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec b/custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec
new file mode 100644
index 00000000..3566271b
--- /dev/null
+++ b/custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec
@@ -0,0 +1,12 @@
+Gem::Specification.new do |s|
+ s.name = 'openproject-livesolutions'
+ s.version = '1.0.0'
+ s.authors = ['Live Solutions']
+ s.email = ['anthony@livesolutionsnow.com']
+ s.summary = 'Live Solutions brand plugin for OpenProject'
+ s.description = 'Injects custom CSS, favicon and logo for Live Solutions brand identity on OpenProject Community Edition.'
+ s.license = 'GPL-3.0-or-later'
+
+ s.files = Dir['lib/**/*', 'app/**/*', 'config/**/*', 'README.md']
+ s.require_paths = ['lib']
+end
diff --git a/docker-compose.control.yml b/docker-compose.control.yml
index 6360b31c..22ab78f1 100644
--- a/docker-compose.control.yml
+++ b/docker-compose.control.yml
@@ -30,6 +30,20 @@ services:
- "./backups:/backups"
- "./control:/control"
entrypoint: ["/control/backup/entrypoint.sh"]
+ restore:
+ restart: "no"
+ build:
+ context: ./control
+ environment:
+ PGDATA: /var/lib/postgresql/data
+ OPDATA: /var/openproject/assets
+ RESTORE_TIMESTAMP: ${RESTORE_TIMESTAMP:-}
+ volumes:
+ - "${PGDATA:-pgdata}:/var/lib/postgresql/data"
+ - "${OPDATA:-opdata}:/var/openproject/assets"
+ - "./backups:/backups"
+ - "./control:/control"
+ entrypoint: ["/control/restore/entrypoint.sh"]
web:
restart: "no"
entrypoint: ["echo", "disabled"]
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
new file mode 100644
index 00000000..8b347036
--- /dev/null
+++ b/docker-compose.override.yml
@@ -0,0 +1,219 @@
+# OpenProject 17 Community Edition — Live Solutions WSL2 override
+# Purpose: attach to existing ai-stack_ai-net for Cloudflare Tunnel, remove host port publishing,
+# apply resource limits, mount brand assets + initializers, and build custom image with plugins.
+# This file is merged with upstream docker-compose.yml and survives `git pull` updates.
+
+services:
+ db:
+ # Dedicated postgres instance for OpenProject (segregated from ai-postgres)
+ image: postgres:17
+ deploy:
+ resources:
+ limits:
+ cpus: "1.00"
+ memory: 2G
+ # Memory tuning for the 2 GiB container limit.
+ command:
+ - "postgres"
+ - "-c"
+ - "shared_buffers=512MB"
+ - "-c"
+ - "work_mem=16MB"
+ - "-c"
+ - "effective_cache_size=1GB"
+ - "-c"
+ - "maintenance_work_mem=256MB"
+ volumes:
+ - "openproject_pgdata:/var/lib/postgresql/data"
+
+ cache:
+ deploy:
+ resources:
+ limits:
+ cpus: "0.50"
+ memory: 256M
+
+ proxy:
+ # No host port publish — Cloudflare Tunnel reaches this on ai-stack_ai-net via container name.
+ ports: !reset []
+ networks:
+ - frontend
+ - ai-stack_ai-net
+ deploy:
+ resources:
+ limits:
+ cpus: "0.50"
+ memory: 128M
+ volumes:
+ - "./proxy/Caddyfile:/etc/caddy/Caddyfile:ro"
+ - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro"
+ # Live Solutions runtime JS patches (requests #777, #778) — proxied
+ # by Caddy via the /javascripts/livesolutions/* route added to
+ # proxy/Caddyfile. Mounted read-only into the proxy container so
+ # Caddy can file_server them directly.
+ - "./custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions:/app/public/javascripts/livesolutions:ro"
+ healthcheck:
+ test: ["CMD", "caddy", "version"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ hostname: projects.livesolutionsnow.com
+ env_file: .env
+ deploy:
+ resources:
+ limits:
+ cpus: "2.00"
+ memory: 4G
+ volumes:
+ - "${OPDATA:-opdata}:/var/openproject/assets"
+ # Initializers (Cloudflare auth, PDF logo sizing, email suppression, etc.)
+ - "./openproject-config/initializers:/app/config/initializers/custom:ro"
+ # Live Solutions branding assets (served by Caddy and mounted into Rails public)
+ - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro"
+ - "./openproject-config/branding/generated/favicon.ico:/app/public/favicon.ico:ro"
+ # Replace default OpenProject logo assets so exports use LS branding
+ - "./openproject-config/branding/generated/logo_openproject.png:/app/app/assets/images/logo_openproject.png:ro"
+ - "./openproject-config/branding/generated/logo_openproject_white_big.png:/app/app/assets/images/logo_openproject_white_big.png:ro"
+ # Theme CSS (hot-swappable without rebuild)
+ - "./custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css:/app/public/stylesheets/livesolutions-theme.css:ro"
+ # Pagination default JS (request #778) — hot-swappable without rebuild,
+ # served directly from /app/public/javascripts/livesolutions/
+ - "./custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/pagination-default.js:/app/public/javascripts/livesolutions/pagination-default.js:ro"
+ # Hierarchy Collapse-all / Expand-all JS (request #777) — same mount
+ # pattern as the pagination script above. Injected into the
+ # application via the existing plugin view override at
+ # app/views/common/_favicons.html.erb.
+ - "./custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js:/app/public/javascripts/livesolutions/hierarchy-collapse-all.js:ro"
+ # View overrides (logo sizing, favicon, homescreen, bulk-copy error safe-linking)
+ - "./custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb:/app/app/views/custom_styles/_inline_css_logo.erb:ro"
+ - "./custom-plugin/openproject-livesolutions/app/views/common/_favicons.html.erb:/app/app/views/common/_favicons.html.erb:ro"
+ - "./custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb:/app/app/views/homescreen/index.html.erb:ro"
+ - "./custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb:/app/app/views/work_packages/bulk/_errors.html.erb:ro"
+ # Work-package list bootstrap: inject per-user hierarchy collapse state into
+ # the page as a meta tag so the runtime JS can restore it before
+ # Angular renders the table rows (request #777 optimization).
+ - "./custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb:/app/app/views/work_packages/index.html.erb:ro"
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080${OPENPROJECT_RAILS__RELATIVE__URL_ROOT:-}/health_checks/default"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 30s
+ labels:
+ - autoheal=true
+
+ worker:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file: .env
+ deploy:
+ resources:
+ limits:
+ cpus: "1.00"
+ memory: 2G
+ volumes:
+ - "${OPDATA:-opdata}:/var/openproject/assets"
+ - "./openproject-config/initializers:/app/config/initializers/custom:ro"
+ - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro"
+ - "./openproject-config/branding/generated/favicon.ico:/app/public/favicon.ico:ro"
+ - "./openproject-config/branding/generated/logo_openproject.png:/app/app/assets/images/logo_openproject.png:ro"
+ - "./openproject-config/branding/generated/logo_openproject_white_big.png:/app/app/assets/images/logo_openproject_white_big.png:ro"
+ - "./custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb:/app/app/views/custom_styles/_inline_css_logo.erb:ro"
+ - "./custom-plugin/openproject-livesolutions/app/views/common/_favicons.html.erb:/app/app/views/common/_favicons.html.erb:ro"
+ - "./custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb:/app/app/views/work_packages/bulk/_errors.html.erb:ro"
+
+ cron:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file: .env
+ deploy:
+ resources:
+ limits:
+ cpus: "0.50"
+ memory: 512M
+ volumes:
+ - "${OPDATA:-opdata}:/var/openproject/assets"
+ - "./openproject-config/initializers:/app/config/initializers/custom:ro"
+ - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro"
+ - "./openproject-config/branding/generated/favicon.ico:/app/public/favicon.ico:ro"
+ - "./openproject-config/branding/generated/logo_openproject.png:/app/app/assets/images/logo_openproject.png:ro"
+ - "./openproject-config/branding/generated/logo_openproject_white_big.png:/app/app/assets/images/logo_openproject_white_big.png:ro"
+
+ seeder:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file: .env
+ deploy:
+ resources:
+ limits:
+ cpus: "1.00"
+ memory: 1G
+
+ autoheal:
+ deploy:
+ resources:
+ limits:
+ cpus: "0.25"
+ memory: 64M
+ # Restart the autoheal supervisor if it exits.
+ restart: unless-stopped
+
+ hocuspocus:
+ deploy:
+ resources:
+ limits:
+ cpus: "0.50"
+ memory: 256M
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "node", "-e", "require('net').createConnection(1234,'localhost').on('connect',function(){this.end()}).on('error',function(){process.exit(1)})"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+ relay:
+ build:
+ context: ./smtp-relay
+ dockerfile: Dockerfile
+ image: openproject-smtp-relay:local
+ restart: unless-stopped
+ environment:
+ RELAY_LISTEN_HOST: 0.0.0.0
+ RELAY_LISTEN_PORT: 587
+ GRAPH_TENANT_ID: ${GRAPH_TENANT_ID}
+ GRAPH_CLIENT_ID: ${GRAPH_CLIENT_ID}
+ GRAPH_CLIENT_SECRET: ${GRAPH_CLIENT_SECRET}
+ DEFAULT_SENDER_UPN: ${DEFAULT_SENDER_UPN:-general@livesolutionsnow.com}
+ ALLOWED_SENDERS: ${ALLOWED_SENDERS:-general@livesolutionsnow.com,anthony@livesolutionsnow.com}
+ networks:
+ - backend
+ deploy:
+ resources:
+ limits:
+ cpus: "0.50"
+ memory: 256M
+ healthcheck:
+ test: ["CMD", "python3", "-c", "import socket; s=socket.socket(); s.connect(('127.0.0.1',587)); s.close()"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 10s
+
+networks:
+ ai-stack_ai-net:
+ external: true
+ name: ai-stack_ai-net
+
+volumes:
+ openproject_pgdata:
diff --git a/docs/PREMIUM_FEATURE_UNLOCK_PLAN.md b/docs/PREMIUM_FEATURE_UNLOCK_PLAN.md
new file mode 100644
index 00000000..4bacc87f
--- /dev/null
+++ b/docs/PREMIUM_FEATURE_UNLOCK_PLAN.md
@@ -0,0 +1,197 @@
+# OpenProject Premium Feature Unlock Plan
+
+**Deployment:** Live Solutions OpenProject 17 Community Edition (`openproject/openproject:17-slim`)
+**Goal:** Unlock 22 enterprise/premium features and suppress all upsell/locked UI, without modifying the Docker image or using a paid enterprise token.
+**Status:** Implemented and deployed.
+
+
+## Key architectural insight
+
+The OpenProject Community image ships **the complete source code for every premium feature**. Modules such as `team_planner`, `meeting`, `storages`, `costs`, `backlogs`, and `bim` are all loaded at runtime. The only difference between Community and Enterprise is a single gate:
+
+```ruby
+EnterpriseToken.allows_to?(:feature_symbol)
+```
+
+Without an enterprise token this returns `false`, which disables menu items, hides controller actions, renders upsell banners, and rejects contract validations. Overriding that method to return `true` for selected feature symbols is therefore sufficient to activate the native premium functionality and remove the upsell surface.
+
+This approach is **image-safe, DB-safe, and fully reversible**: deleting the initializer and restarting re-locks everything.
+
+## Feature scope
+
+### Basic-tier features unlocked
+
+| # | Feature | Symbol | Notes |
+|---|---------|--------|-------|
+| 1 | Baseline comparisons (full date range) | `:baseline_comparison` | Community already has "compare to yesterday"; this unlocks any date range |
+| 2 | Custom action buttons | `:custom_actions` | One-click workflow transition buttons |
+| 3 | Hierarchy custom fields | `:custom_field_hierarchies` | Multi-level hierarchical CF values |
+| 4 | Date alerts | `:date_alerts` | Upcoming/overdue reminders |
+| 5 | Configure work package forms | `:edit_attribute_groups` | Rearrange attribute groups and related-WP tables per WP type |
+| 6 | Gantt PDF export | `:gantt_pdf_export` | Export Gantt charts to PDF |
+| 7 | Placeholder users | `:placeholder_users` | Users without email, assignable to projects |
+| 8 | Read-only work packages | `:readonly_work_packages` | Lock WP status from further edits |
+| 9 | Team planner | `:team_planner_view` | Plan work by team member × week |
+| 10 | Relations in work package table | `:work_package_query_relation_columns` | Inline relation columns in WP table |
+| 11 | Reusable meeting templates | `:meeting_templates` | Template-driven meetings |
+
+### Professional-tier features unlocked
+
+| # | Feature | Symbol | Notes |
+|---|---------|--------|-------|
+| 12 | OneDrive / SharePoint storage | `:one_drive_sharepoint_file_storage` | Requires Azure AD OAuth app setup |
+| 13 | Share work packages externally | `:work_package_sharing` | Cross-organization WP sharing |
+| 14 | MCP / AI server | `:mcp_server` | Enables native `/api/mcp` endpoint |
+| 15 | Internal comments | `:internal_comments` | Restricted WP comments |
+| 16 | Require exact time tracking | `:time_entry_time_restrictions` | Force start/end time entry |
+
+### Premium-tier features unlocked
+
+| # | Feature | Symbol | Notes |
+|---|---------|--------|-------|
+| 17 | Portfolio management | `:portfolio_management` | High-level portfolio view |
+| 18 | Project initiation request | `:project_creation_wizard` | Standardized project creation wizard |
+| 19 | Customize project life cycle | `:customize_life_cycle` | Edit/rearrange phases and gates |
+| 20 | Capture external links | `:capture_external_links` | Warn before external navigation |
+| 21 | Share project lists | `:project_list_sharing` | Share project lists with users/groups |
+| 22 | Project scoring/evaluation | `:calculated_values`, `:weighted_item_lists` | Calculated CF values and weighted item lists |
+
+## Suppression scope
+
+In addition to unlocking the features above, the implementation suppresses the entire enterprise upsell surface:
+
+- `EnterpriseEdition::BannerComponent` is hidden via `Setting.ee_hide_banners = true`
+- Trial teaser banners are suppressed
+- The admin **Enterprise** page is hidden via `Setting.ee_manager_visible = false`
+- Enterprise lock icons on menus disappear because `EnterpriseToken.allows_to?` now returns `true`
+
+Remaining locked features (SSO providers, LDAP group sync, SCIM, ClamAV, sprint sharing, Nextcloud OIDC, openDesk, BIM/IFC) stay locked and their upsell UI is also suppressed.
+
+## Implementation files
+
+| File | Purpose |
+|------|---------|
+| `openproject-config/initializers/community_unlock.rb` | Overrides `EnterpriseToken.allows_to?`, sets banner/admin suppression |
+| `.env` | Adds OneDrive/SharePoint OAuth placeholders |
+| `~/ai-stack/openproject-mcp/` (new) | Custom MCP bridge server exposing OpenProject tools to ai-stack |
+| `~/ai-stack/config/mcpo-config.json` | Registers the native and custom MCP endpoints |
+
+## Implementation summary
+
+### Step 1 — Deploy unlock initializer
+
+Created `openproject-config/initializers/community_unlock.rb` with the `UNLOCKED_FEATURES` set and `EnterpriseToken` prepend. It is automatically mounted via the existing `docker-compose.override.yml` bind-mount:
+
+```yaml
+volumes:
+ - ./openproject-config/initializers:/app/config/initializers/custom:ro
+```
+
+The initializer:
+- Prepends `EnterpriseToken.singleton_class` so `allows_to?(feature)` returns `true` for the 23 selected feature symbols.
+- Sets `EnterpriseToken.active?` to `true` for guards that short-circuit before `allows_to?`.
+- Sets `Setting.ee_hide_banners = true` to suppress `EnterpriseEdition::BannerComponent` renders.
+- Sets `OpenProject::Configuration["ee_manager_visible"] = false` to hide the admin Enterprise page.
+
+### Step 2 — OneDrive / SharePoint OAuth
+
+Added placeholder env vars to `.env`:
+
+```env
+OPENPROJECT_STORAGES__ONEDRIVE_CLIENT_ID=
+OPENPROJECT_STORAGES__ONEDRIVE_CLIENT_SECRET=
+```
+
+Remaining manual step: register a new Azure AD / Entra ID application:
+
+- Name: `OpenProject Storages`
+- Redirect URI: `https://projects.livesolutionsnow.com/oauth2/callback`
+- API permissions: `Files.ReadWrite.All`, `Sites.ReadWrite.All`, `User.Read`, `offline_access`
+- Generate a client secret, paste into `.env`, restart OpenProject.
+
+Then in OpenProject UI: **Administration → File storages → New storage → OneDrive/SharePoint** and authorize the application.
+
+### Step 3 — MCP / AI integration
+
+#### 3A. Native MCP endpoint
+
+Unlocking `:mcp_server` changed OpenProject's built-in `/api/mcp` endpoint from HTTP 404 to HTTP 400 (it now expects a valid MCP request body). The native endpoint is live and can be consumed by any MCP client.
+
+#### 3B. Custom ai-stack MCP bridge
+
+Built a Python MCP server in `~/ai-stack/openproject-mcp/` that translates OpenProject REST API calls into MCP tools. The server is bundled into a custom `mcpo:openproject` image and registered in `~/ai-stack/config/mcpo-config.json`.
+
+Exposed tools (12):
+
+- `list_projects`, `get_project`
+- `list_work_packages`, `get_work_package`, `create_work_package`, `update_work_package`
+- `search_work_packages`
+- `update_wp_status`
+- `list_statuses`, `list_types`
+- `list_meetings`, `get_meeting`
+
+The bridge authenticates to OpenProject using the API token in `OPENPROJECT_API_TOKEN`. Because the call goes through the internal `openproject-proxy-1` container, the server injects `Host: projects.livesolutionsnow.com` so OpenProject accepts the request while traffic stays on the Docker network.
+
+### Step 4 — Restart and verify
+
+```bash
+# OpenProject
+cd /home/anthonyturgman/openproject
+docker compose restart web worker cron seeder
+
+# ai-stack MCP
+cd /home/anthonyturgman/ai-stack
+docker compose up -d --build mcpo
+```
+
+Verification checklist and results are in the next section.
+
+## Verification checklist
+
+| Feature | Where to check | Status |
+|---------|---------------|--------|
+| Baseline comparison | WP → More → Baselines | ✅ Unlocked via `:baseline_comparison` |
+| Custom actions | Administration → Custom Actions | ✅ Unlocked via `:custom_actions` |
+| Hierarchy CF | Administration → Custom Fields | ✅ Unlocked via `:custom_field_hierarchies` |
+| Date alerts | My Page → Notifications | ✅ Unlocked via `:date_alerts` |
+| WP form config | Administration → Work package types → Form configuration | ✅ Unlocked via `:edit_attribute_groups` |
+| Gantt PDF | Gantt view → Export | ✅ Unlocked via `:gantt_pdf_export` |
+| Placeholder users | Invite user form | ✅ Unlocked via `:placeholder_users` |
+| WP read-only | Administration → Statuses | ✅ Unlocked via `:readonly_work_packages` |
+| Team planner | Project sidebar | ✅ Unlocked via `:team_planner_view` |
+| Relations in table | WP table → Configure → Columns | ✅ Unlocked via `:work_package_query_relation_columns` |
+| Meeting templates | Project → Meetings | ✅ Unlocked via `:meeting_templates` |
+| OneDrive/SharePoint | Administration → File storages | ✅ UI unlocked; OAuth credentials pending |
+| Share WPs | Work package → Share | ✅ Unlocked via `:work_package_sharing` |
+| Internal comments | WP → Activity | ✅ Unlocked via `:internal_comments` |
+| Exact time tracking | Administration → Time and costs → Time tracking settings | ✅ Unlocked via `:time_entry_time_restrictions` |
+| Portfolio mgmt | Projects → Portfolio | ✅ Unlocked via `:portfolio_management` |
+| Project initiation | Projects → New | ✅ Unlocked via `:project_creation_wizard` |
+| Life cycle customize | Project settings → Phases | ✅ Unlocked via `:customize_life_cycle` |
+| External link capture | Administration → System settings → External links | ✅ Unlocked via `:capture_external_links` |
+| Share project lists | Project lists → Share | ✅ Unlocked via `:project_list_sharing` |
+| Calculated values | Administration → Custom Fields | ✅ Unlocked via `:calculated_values` |
+| Weighted items | Administration → Custom Fields | ✅ Unlocked via `:weighted_item_lists` |
+| Suppression | Everywhere | ✅ `ee_hide_banners=true`, `ee_manager_visible=false` |
+| Native MCP | `GET /api/mcp` | ✅ Unlocked (now returns 400 on empty request, not 404) |
+| Custom MCP bridge | `http://mcpo:8000/openproject/openapi.json` | ✅ Returns 12 tool paths |
+
+## Rollback
+
+To revert all changes:
+
+1. Delete `openproject-config/initializers/community_unlock.rb`
+2. Remove OneDrive env vars from `.env` if desired
+3. Stop the `openproject-mcp` ai-stack service and remove its mcpo registration
+4. Restart OpenProject containers
+
+All premium features re-lock and the enterprise upsell UI reappears for the previously locked items.
+
+## Security and maintenance notes
+
+- No enterprise token is forged or injected. The override is a runtime monkey-patch.
+- The initializer is idempotent because it uses `prepend` inside `to_prepare` and checks `Setting.table_exists?` before writing settings.
+- Custom plugins are not auto-loaded by Community edition, so the override lives in the mounted initializer directory rather than in the `custom-plugin/` engine.
+- For OneDrive/SharePoint, follow the principle of least privilege when registering the Azure AD app.
+- The MCP bridge API token should be a dedicated, low-privilege OpenProject account or service account.
+- Before major OpenProject upgrades, verify that the `EnterpriseToken` class signature has not changed; the override is small and easy to adapt.
diff --git a/docs/UPGRADE_HARDENING.md b/docs/UPGRADE_HARDENING.md
new file mode 100644
index 00000000..6df98613
--- /dev/null
+++ b/docs/UPGRADE_HARDENING.md
@@ -0,0 +1,172 @@
+# OpenProject Upgrade Hardening Guide
+
+This document describes the hardening layer added to this OpenProject Docker
+Compose deployment so that upstream updates can be applied without data loss,
+formatting/style regressions, or prolonged outages.
+
+It is stored in `docs/` (not in the upstream `README.md`) so it survives
+`git pull origin stable/17`.
+
+## 1. What is protected
+
+| Asset | Location / mechanism | Protection |
+|-------|----------------------|------------|
+| PostgreSQL data | Named volume `openproject_openproject_pgdata` | Offline physical tarball + live logical `pg_dumpall` |
+| OpenProject attachments/assets | Bind mount `/var/openproject/assets` | Offline tarball in `backups/` |
+| Brand CSS / custom theme | `custom-plugin/openproject-livesolutions/.../theme.css` mounted to `/app/public/stylesheets/livesolutions-theme.css` | Health check verifies it is served after every restart |
+| Custom plugin | `custom-plugin/openproject-livesolutions/` copied into image by `Dockerfile` | Verified present by health check; version compatibility still needs manual review on major upgrades |
+| Cloudflare Access SSO | `openproject-config/initializers/cloudflare_access_auth.rb` mounted to `/app/config/initializers/custom/` | Verified present by health check |
+| Hierarchy collapse state (#777) | `db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb` + backend API | Migration must be run after image rebuild; state persists per user across browsers |
+| Pagination default 100 (#778) | `lib/open_project/livesolutions/patches/query_params_representer_patch.rb` | Enforced at API level for all users and queries |
+| Premium feature unlock | `openproject-config/initializers/community_unlock.rb` mounted to `/app/config/initializers/custom/` | 22 enterprise features unlocked; verified present by feature check |
+| Proxy / Caddyfile | `proxy/Caddyfile` bind-mounted | Preserved during `git pull` by upgrade wrapper |
+| Secrets | `.env` (gitignored) | Never committed; restored automatically after `git pull` |
+
+## 2. Files added by hardening
+
+```text
+scripts/
+ upgrade.sh # Orchestrated upgrade with backup, dry-run, rollback
+ health-check.sh # Post-upgrade / periodic validation
+ backup-logical.sh # Live logical SQL backup while stack is up
+control/
+ Dockerfile # Now based on postgres:17-bookworm with PG upgrade tools
+ backup/entrypoint.sh # Physical offline backup (PGDATA + OPDATA + manifest)
+ restore/entrypoint.sh # Restore a backup set and relaunch
+ upgrade/scripts/00-db-upgrade.sh # Version-agnostic pg_upgrade wrapper
+docs/
+ UPGRADE_HARDENING.md # This document
+```
+
+## 3. Normal upgrade workflow
+
+Run everything from `/home/anthonyturgman/openproject`.
+
+### 3.1 Preview what upstream will change (safe)
+
+```bash
+./scripts/upgrade.sh --dry-run
+```
+
+This fetches `origin/stable/17`, shows the diff, verifies custom files are
+present, and prints the commands it would run. No containers are modified.
+
+### 3.2 Perform the upgrade
+
+```bash
+./scripts/upgrade.sh
+```
+
+The wrapper executes, in order:
+
+1. Verifies the merged Docker Compose configuration is valid.
+2. Verifies all custom files exist.
+3. Takes an **offline physical backup** of PGDATA and OPDATA with a timestamped
+ manifest in `backups/`.
+4. Pulls upstream changes (`git pull origin stable/17`).
+ - If a merge conflict occurs, it aborts the merge and restores your local
+ `docker-compose.override.yml` and `.env`.
+5. Runs the database major-version upgrade container only if the PG version
+ changed.
+6. Restarts the stack with `docker compose up -d --build --pull always`.
+7. Runs `./scripts/health-check.sh`. If it fails, **automatically restores the
+ backup and restarts the previous version**.
+
+## 4. Manual rollback
+
+If anything is wrong after an upgrade, restore the latest backup and restart:
+
+```bash
+./scripts/upgrade.sh --restore-only
+```
+
+Or restore a specific timestamp:
+
+```bash
+RESTORE_TIMESTAMP=1782965033 docker compose -f docker-compose.yml -f docker-compose.control.yml run --rm restore
+docker compose up -d
+```
+
+## 5. Additional live backups
+
+The offline physical backup used by `upgrade.sh` is tied to the PG version
+running in the volume. For extra safety (especially before a major Postgres
+upgrade), create a portable logical dump while the stack is running:
+
+```bash
+./scripts/backup-logical.sh
+```
+
+The resulting `*.sql.gz` can be restored into a fresh Postgres container of any
+major version.
+
+## 6. Periodic health checks
+
+Run the health checker manually or from a cron job:
+
+```bash
+./scripts/health-check.sh
+```
+
+It verifies:
+
+- Required containers are running.
+- PostgreSQL is ready and the `users` table is readable.
+- The OpenProject `/health_checks/default` endpoint returns success.
+- The Live Solutions brand CSS is reachable.
+- The custom plugin directory is mounted.
+- The Cloudflare Access initializer is present.
+- The hierarchy-collapse backend migration has been applied
+ (`user_hierarchy_collapse_states` table exists).
+- The pagination-default patch is active (an API query returns `pageSize=100`
+ when no explicit page size is requested).
+- The public login page is reachable via Cloudflare Tunnel.
+- The asset directory is accessible.
+
+## 7. Premium features
+
+The deployment unlocks 22 OpenProject Enterprise features via
+`openproject-config/initializers/community_unlock.rb` (see
+`docs/PREMIUM_FEATURE_UNLOCK_PLAN.md`). This file is mounted into the
+container just like the Cloudflare Access initializer.
+
+Before any upgrade, the feature check verifies:
+
+- `community_unlock.rb` exists and is mounted.
+- All 22 expected feature symbols are present in the unlock list.
+- Upsell banner suppression (`ee_hide_banners = true`) is configured.
+
+After the upgrade, the same checks run again; if any fail, the orchestrator
+rolls back to the pre-upgrade backup so the premium features stay unlocked.
+
+## 8. Important caveats
+
+- **`.env` and `docker-compose.override.yml` are gitignored.** The upgrade
+ wrapper restores them after `git pull`, but you should keep an encrypted
+ off-host copy of `.env` as well.
+- **`backups/` may be owned by root** if created by the control-plane container.
+ Ensure the host user can write to it, or `chown -R 1000:1000 backups/` after
+ the first backup.
+- **Major OpenProject version upgrades** (e.g., 17 → 18) may break custom
+ plugin hooks or CSS selectors. The health checks will catch a missing CSS
+ file or initializer, but you must still review upstream release notes and
+ update `theme.css` / plugin code as needed.
+- **Major PostgreSQL upgrades** from a version older than 17 require PG binaries
+ for both the old and new versions in the control image. If you are on PG 13 and
+ need to migrate to 17, extend `control/Dockerfile` to install the old
+ `postgresql-13` package from apt.postgresql.org, or restore a logical backup
+ into a fresh PG 17 volume.
+
+## 9. Disaster-recovery checklist
+
+1. Stop the stack: `docker compose down`
+2. Identify the backup timestamp to restore: `ls -1 backups/*-manifest.txt`
+3. Restore: `RESTORE_TIMESTAMP= docker compose -f docker-compose.yml -f docker-compose.control.yml run --rm restore`
+4. Start: `docker compose up -d`
+5. Validate: `./scripts/health-check.sh`
+
+## 10. References
+
+- Upstream compose repo: https://github.com/opf/openproject-docker-compose
+- OpenProject Docker upgrade docs: https://www.openproject.org/docs/installation-and-operations/installation/docker/
+- PostgreSQL pg_upgrade docs: https://www.postgresql.org/docs/current/pgupgrade.html
diff --git a/openproject-config/branding/generated/apple-touch-icon-120x120.png b/openproject-config/branding/generated/apple-touch-icon-120x120.png
new file mode 100644
index 00000000..da8b9163
Binary files /dev/null and b/openproject-config/branding/generated/apple-touch-icon-120x120.png differ
diff --git a/openproject-config/branding/generated/favicon.ico b/openproject-config/branding/generated/favicon.ico
new file mode 100644
index 00000000..23b6773e
Binary files /dev/null and b/openproject-config/branding/generated/favicon.ico differ
diff --git a/openproject-config/branding/generated/favicon_32.png b/openproject-config/branding/generated/favicon_32.png
new file mode 100644
index 00000000..b35e3c80
Binary files /dev/null and b/openproject-config/branding/generated/favicon_32.png differ
diff --git a/openproject-config/branding/generated/icon_logo.png b/openproject-config/branding/generated/icon_logo.png
new file mode 100644
index 00000000..7173e28b
Binary files /dev/null and b/openproject-config/branding/generated/icon_logo.png differ
diff --git a/openproject-config/branding/generated/icon_logo_white.png b/openproject-config/branding/generated/icon_logo_white.png
new file mode 100644
index 00000000..0d672fa9
Binary files /dev/null and b/openproject-config/branding/generated/icon_logo_white.png differ
diff --git a/openproject-config/branding/generated/logo_openproject.png b/openproject-config/branding/generated/logo_openproject.png
new file mode 100644
index 00000000..447c7835
Binary files /dev/null and b/openproject-config/branding/generated/logo_openproject.png differ
diff --git a/openproject-config/branding/generated/logo_openproject_white_big.png b/openproject-config/branding/generated/logo_openproject_white_big.png
new file mode 100644
index 00000000..9777d2e0
Binary files /dev/null and b/openproject-config/branding/generated/logo_openproject_white_big.png differ
diff --git a/openproject-config/branding/ls_cube_source.png b/openproject-config/branding/ls_cube_source.png
new file mode 100644
index 00000000..b28070e1
Binary files /dev/null and b/openproject-config/branding/ls_cube_source.png differ
diff --git a/openproject-config/branding/ls_full_dark.png b/openproject-config/branding/ls_full_dark.png
new file mode 100644
index 00000000..5b2cd093
Binary files /dev/null and b/openproject-config/branding/ls_full_dark.png differ
diff --git a/openproject-config/branding/ls_logo_source.png b/openproject-config/branding/ls_logo_source.png
new file mode 100644
index 00000000..9ae18923
Binary files /dev/null and b/openproject-config/branding/ls_logo_source.png differ
diff --git a/openproject-config/branding/ls_logo_white_source.png b/openproject-config/branding/ls_logo_white_source.png
new file mode 100644
index 00000000..8c418ebf
Binary files /dev/null and b/openproject-config/branding/ls_logo_white_source.png differ
diff --git a/openproject-config/branding/ls_mark.png b/openproject-config/branding/ls_mark.png
new file mode 100644
index 00000000..b28070e1
Binary files /dev/null and b/openproject-config/branding/ls_mark.png differ
diff --git a/openproject-config/initializers/ls_pdf_logo_size.rb b/openproject-config/initializers/ls_pdf_logo_size.rb
new file mode 100644
index 00000000..0a024930
--- /dev/null
+++ b/openproject-config/initializers/ls_pdf_logo_size.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Live Solutions: enlarge the default PDF export logo rendering so the full
+# LS wordmark remains readable on covers and page headers.
+
+Rails.application.config.to_prepare do
+ # Project / work package PDF exports
+ Exports::PDF::Components::CoverStyles.module_eval do
+ def cover_header_logo_height
+ resolve_pt(@styles.dig(:cover, :header, :logo_height), 60)
+ end
+ end
+
+ Exports::PDF::Components::PageStyles.module_eval do
+ def page_logo_height
+ resolve_pt(@styles.dig(:page_logo, :height), 35)
+ end
+ end
+end
diff --git a/proxy/Caddyfile b/proxy/Caddyfile
new file mode 100644
index 00000000..09d44089
--- /dev/null
+++ b/proxy/Caddyfile
@@ -0,0 +1,66 @@
+:80 {
+ # Global monitoring endpoint reachable on status.livesolutionsnow.com.
+ # The Caddy default virtual host will serve this for any hostname; the
+ # Cloudflare Tunnel routes status.livesolutionsnow.com to this container
+ # without Access, so no authentication is required.
+ handle /healthz {
+ respond "OK" 200
+ }
+
+ # Serve Live Solutions branding assets directly; Rails/Puma does not serve
+ # static files in production mode, and these must be reachable for the
+ # favicon, logos, and export headers.
+ handle_path /livesolutions-branding/* {
+ root * /app/public/livesolutions-branding
+ file_server
+ }
+
+ # Serve Live Solutions runtime JS patches directly. The plugin engine
+ # mounts each patch into /app/public/javascripts/livesolutions/ via
+ # docker-compose.override.yml (see Request #777 hierarchy-collapse-all.js
+ # and Request #778 pagination-default.js). Without this route the JS
+ # requests fall through to the Rails reverse proxy and the browser
+ # receives the OP HTML instead of the script.
+ handle_path /javascripts/livesolutions/* {
+ root * /app/public/javascripts/livesolutions
+ file_server
+ }
+
+ # Serve root favicon directly from the branding set.
+ handle /favicon.ico {
+ root * /app/public/livesolutions-branding
+ rewrite * /favicon.ico
+ file_server
+ }
+
+ reverse_proxy /hocuspocus* hocuspocus:1234
+
+ # Agent terminal: proxied path to the host-side Flask receiver running
+ # on port 8799. The receiver serves the xterm.js page and upgrades
+ # WebSocket connections to a live opencode PTY session.
+ handle_path /agent-terminal/* {
+ reverse_proxy 172.18.0.1:8799 {
+ # WebSocket upgrade requires HTTP/1.1 to the backend.
+ transport http {
+ versions 1.1
+ }
+ # Preserve the original Host so Werkzeug's WebSocket upgrade doesn't 400.
+ header_up Host {host}
+ }
+ }
+
+ reverse_proxy * http://web:8080 {
+ # Public traffic is always HTTPS via Cloudflare Tunnel, so tell Rails/OpenProject
+ # the original protocol was HTTPS. This prevents redirect loops between HTTP and HTTPS.
+ header_up X-Forwarded-Proto https
+ header_up X-Forwarded-For {header.X-Forwarded-For}
+ # Always present the canonical hostname to Rails. This keeps internal
+ # requests (e.g., Playwright tests reaching the proxy by container IP)
+ # from being rejected by OpenProject's host_name check.
+ header_up Host projects.livesolutionsnow.com
+ }
+
+ file_server
+
+ log
+}
diff --git a/scripts/backup-logical.sh b/scripts/backup-logical.sh
new file mode 100755
index 00000000..84c87c04
--- /dev/null
+++ b/scripts/backup-logical.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# Live logical PostgreSQL backup.
+# Runs pg_dumpall against the running db container so it can be restored into a
+# different Postgres major version. This does NOT replace the offline physical
+# backup used by the upgrade orchestrator; it is an additional safety net.
+#
+# Usage:
+# ./scripts/backup-logical.sh
+
+set -e
+set -o pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+mkdir -p backups
+timestamp=$(date +%s)
+file="backups/${timestamp}-openproject.sql.gz"
+
+# If the backups directory is root-owned from previous control-plane runs, fall
+# back to /tmp/opencode/ so the script still succeeds for the current user.
+if [ ! -w "backups" ]; then
+ echo "WARN: backups/ is not writable by $(id -un); writing to /tmp/opencode/ instead." >&2
+ mkdir -p /tmp/opencode
+ file="/tmp/opencode/${timestamp}-openproject.sql.gz"
+fi
+
+echo "Creating logical backup: ${file} ..."
+docker compose exec -T db pg_dumpall -U postgres --clean --if-exists | gzip > "${file}"
+echo "Logical backup complete: ${file}"
+ls -lh "${file}"
diff --git a/scripts/feature-check.sh b/scripts/feature-check.sh
new file mode 100755
index 00000000..484e9d85
--- /dev/null
+++ b/scripts/feature-check.sh
@@ -0,0 +1,305 @@
+#!/bin/bash
+# OpenProject feature / compatibility check.
+#
+# Verifies that Live Solutions customisations and critical integrations remain
+# intact and compatible with the configured images. Run before an upgrade to
+# establish a baseline, and after an upgrade to confirm nothing broke.
+#
+# Usage:
+# ./scripts/feature-check.sh
+#
+# Exit codes:
+# 0 = all checks passed
+# 1 = one or more compatibility issues detected
+
+set -e
+set -o pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+FAILED=0
+WARNINGS=0
+
+log() {
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
+}
+
+ok() {
+ log "OK: $*"
+}
+
+warn() {
+ log "WARN: $*" >&2
+ WARNINGS=$((WARNINGS+1))
+}
+
+fail() {
+ log "FAIL: $*" >&2
+ FAILED=$((FAILED+1))
+}
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+compose_value() {
+ # Extract a top-level value from merged compose config.
+ docker compose config 2>/dev/null | grep -m1 -E "^\s+${1}:" | sed -E 's/^[^:]+: *"?([^"]*)"?$/\1/'
+}
+
+# ---------------------------------------------------------------------------
+# 1. Cloudflare Access SSO
+# ---------------------------------------------------------------------------
+log "=== Feature: Cloudflare Access SSO ==="
+
+if [ -f "openproject-config/initializers/cloudflare_access_auth.rb" ]; then
+ ok "cloudflare_access_auth.rb initializer exists"
+else
+ fail "cloudflare_access_auth.rb initializer is missing"
+fi
+
+if grep -q "TRUST_CF_ACCESS_EMAIL=true" .env 2>/dev/null; then
+ ok ".env enables TRUST_CF_ACCESS_EMAIL"
+else
+ fail ".env does not enable TRUST_CF_ACCESS_EMAIL; CF SSO will not work"
+fi
+
+if grep -q 'Cf-Access-Authenticated-User-Email' openproject-config/initializers/cloudflare_access_auth.rb; then
+ ok "Initializer reads Cf-Access-Authenticated-User-Email header"
+else
+ fail "Initializer missing CF Access email header logic"
+fi
+
+# Make sure we are not also wiring OpenProject native OIDC for the same provider;
+# that would conflict with the header-based SSO.
+if grep -qE '^OPENPROJECT_OPENID__CONNECT__ENTRA__' .env 2>/dev/null; then
+ warn "Native Entra OIDC variables are uncommented in .env; these conflict with CF Access SSO in Community Edition"
+else
+ ok "Native Entra OIDC variables are not active"
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Brand theme CSS
+# ---------------------------------------------------------------------------
+log "=== Feature: Live Solutions brand theme CSS ==="
+
+if [ -f "custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css" ]; then
+ ok "Brand theme CSS file exists"
+else
+ fail "Brand theme CSS file is missing"
+fi
+
+# Verify the file contains our signature tokens; OpenProject upgrades that
+# rename/deprecate these selectors would be caught during the CSS health check,
+# but we flag the risk here too.
+for token in "--ls-action" "--ls-primary" ".op-app-header" "#main-menu"; do
+ if grep -q -F -- "${token}" custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css; then
+ ok "CSS contains brand token: ${token}"
+ else
+ fail "CSS missing brand token: ${token}"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 3. Custom plugin structure
+# ---------------------------------------------------------------------------
+log "=== Feature: openproject-livesolutions plugin ==="
+
+plugin_dir="custom-plugin/openproject-livesolutions"
+required_plugin_files=(
+ "${plugin_dir}/openproject-livesolutions.gemspec"
+ "${plugin_dir}/lib/openproject-livesolutions.rb"
+ "${plugin_dir}/lib/open_project/livesolutions.rb"
+ "${plugin_dir}/lib/open_project/livesolutions/engine.rb"
+ "${plugin_dir}/lib/open_project/livesolutions/hooks.rb"
+ "${plugin_dir}/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb"
+)
+for f in "${required_plugin_files[@]}"; do
+ if [ -f "$f" ]; then
+ ok "Plugin file present: $f"
+ else
+ fail "Plugin file missing: $f"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 4. SMTP relay
+# ---------------------------------------------------------------------------
+log "=== Feature: SMTP-to-Graph relay ==="
+
+if docker compose ps -q relay >/dev/null 2>&1; then
+ ok "relay service has a container"
+else
+ fail "relay service is not running"
+fi
+
+if docker compose exec -T relay python3 -c "import socket; s=socket.socket(); s.connect(('127.0.0.1',587)); s.close()" > /dev/null 2>&1; then
+ ok "relay is listening on port 587"
+else
+ fail "relay is not listening on port 587"
+fi
+
+required_relay_env=(GRAPH_TENANT_ID GRAPH_CLIENT_ID GRAPH_CLIENT_SECRET DEFAULT_SENDER_UPN)
+for var in "${required_relay_env[@]}"; do
+ if grep -qE "^${var}=" .env 2>/dev/null; then
+ ok ".env sets ${var}"
+ else
+ fail ".env missing ${var}; relay will fail"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 5. Proxy / Caddy configuration
+# ---------------------------------------------------------------------------
+log "=== Feature: Caddy reverse proxy ==="
+
+if [ -f "proxy/Caddyfile" ]; then
+ ok "proxy/Caddyfile exists"
+else
+ fail "proxy/Caddyfile is missing"
+fi
+
+if grep -q "reverse_proxy.*web:8080" proxy/Caddyfile; then
+ ok "Caddyfile routes to web:8080"
+else
+ fail "Caddyfile missing route to web:8080"
+fi
+
+if grep -q "X-Forwarded-Proto" proxy/Caddyfile; then
+ ok "Caddyfile forwards/produces X-Forwarded-Proto"
+else
+ warn "Caddyfile does not handle X-Forwarded-Proto; Rails HTTPS detection may break"
+fi
+
+# ---------------------------------------------------------------------------
+# 6. Image / database version compatibility
+# ---------------------------------------------------------------------------
+log "=== Compatibility: image and database versions ==="
+
+tag="${TAG:-$(grep -E '^TAG=' .env | cut -d= -f2 | tr -d '"' || true)}"
+if [ -n "$tag" ]; then
+ ok "OpenProject image tag pinned in .env: ${tag}"
+else
+ warn "TAG not set in .env; upgrade may pull an unexpected major version"
+fi
+
+pg_version_env="${POSTGRES_VERSION:-$(grep -E '^POSTGRES_VERSION=' .env | cut -d= -f2 | tr -d '"' || true)}"
+if [ -n "$pg_version_env" ]; then
+ ok "PostgreSQL version pinned in .env: ${pg_version_env}"
+else
+ warn "POSTGRES_VERSION not set in .env"
+fi
+
+# Compare running DB version with env target
+current_pg="$(docker compose exec -T db cat /var/lib/postgresql/data/PG_VERSION 2>/dev/null || echo unknown)"
+if [ "$current_pg" != "unknown" ]; then
+ ok "Running PostgreSQL major version: ${current_pg}"
+ if [ -n "$pg_version_env" ] && [ "$current_pg" -gt "$pg_version_env" ] 2>/dev/null; then
+ fail "Running PG ${current_pg} is newer than env target ${pg_version_env}; this could cause a downgrade"
+ fi
+else
+ warn "Could not determine running PostgreSQL version"
+fi
+
+# ---------------------------------------------------------------------------
+# 7. Secrets exposure check
+# ---------------------------------------------------------------------------
+log "=== Compatibility: secrets exposure ==="
+
+# The override mounts .env into cron/worker via env_file. We can't easily
+# restrict that in Community Compose, but we can warn if secrets appear in the
+# merged config for services that do not need them.
+compose_config="$(docker compose config 2> /dev/null)"
+if [ -z "$compose_config" ]; then
+ warn "Could not render merged compose config for secrets audit"
+else
+ # If web/worker/cron all share the same env_file, all secrets are visible to
+ # all of them. This is expected with the current override; flag as info.
+ for svc in worker cron; do
+ if echo "$compose_config" | grep -A2 "^ ${svc}:" | grep -q 'env_file: .env'; then
+ warn "Service '${svc}' loads full .env; ensure only required secrets are kept in this file"
+ fi
+ done
+fi
+
+# ---------------------------------------------------------------------------
+# 8. Upgrade-specific file drift guard
+# ---------------------------------------------------------------------------
+log "=== Compatibility: local files survive upstream merge ==="
+
+protected_files=(docker-compose.override.yml .env proxy/Caddyfile)
+for f in "${protected_files[@]}"; do
+ if [ -f "$f" ]; then
+ ok "Protected local file present: $f"
+ else
+ fail "Protected local file missing: $f"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 6. Cloudflare Access initializer present
+# ---------------------------------------------------------------------------
+log "Checking Cloudflare Access initializer..."
+if docker compose exec -T web test -f /app/config/initializers/custom/cloudflare_access_auth.rb; then
+ log "OK: cloudflare_access_auth.rb initializer present"
+else
+ fail "cloudflare_access_auth.rb initializer missing (SSO will break)"
+fi
+
+# ---------------------------------------------------------------------------
+# 7. Premium feature unlock initializer
+# ---------------------------------------------------------------------------
+log "=== Feature: Community premium feature unlock ==="
+
+if [ -f "openproject-config/initializers/community_unlock.rb" ]; then
+ ok "community_unlock.rb initializer exists"
+else
+ fail "community_unlock.rb initializer is missing; 22 premium features will re-lock after upgrade"
+fi
+
+if docker compose exec -T web test -f /app/config/initializers/custom/community_unlock.rb; then
+ ok "community_unlock.rb is mounted inside the web container"
+else
+ fail "community_unlock.rb is not mounted inside the web container"
+fi
+
+expected_unlocked=(
+ baseline_comparison
+ custom_actions
+ custom_field_hierarchies
+ date_alerts
+ edit_attribute_groups
+ gantt_pdf_export
+ placeholder_users
+ readonly_work_packages
+ team_planner_view
+ work_package_query_relation_columns
+ meeting_templates
+ one_drive_sharepoint_file_storage
+ work_package_sharing
+ mcp_server
+ internal_comments
+ time_entry_time_restrictions
+ portfolio_management
+ project_creation_wizard
+ customize_life_cycle
+ capture_external_links
+ project_list_sharing
+ calculated_values
+ weighted_item_lists
+)
+
+for sym in "${expected_unlocked[@]}"; do
+ if grep -q -F "${sym}" openproject-config/initializers/community_unlock.rb; then
+ ok "Premium feature symbol present: ${sym}"
+ else
+ fail "Premium feature symbol missing: ${sym}"
+ fi
+done
+
+if grep -q 'Setting.ee_hide_banners = true' openproject-config/initializers/community_unlock.rb; then
+ ok "Upsell banners suppressed (ee_hide_banners = true)"
+else
+ warn "Upsell banner suppression missing"
+fi
+
diff --git a/scripts/global-health-check.sh b/scripts/global-health-check.sh
new file mode 100755
index 00000000..63222cfb
--- /dev/null
+++ b/scripts/global-health-check.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Global Livesolutions stack health check — runs from the OpenProject host.
+# Checks all Cloudflare Tunnel public endpoints plus local stack components.
+# Returns 0 only if all monitored endpoints pass.
+
+set -euo pipefail
+
+PASS=0
+FAIL=0
+
+check_url() {
+ local name="$1"
+ local url="$2"
+ local expected="${3:-200}"
+ local code
+ code=$(curl -sS -L --max-time 20 -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
+ if [ "$code" = "$expected" ]; then
+ echo "[PASS] $name ($url) -> HTTP $code"
+ PASS=$((PASS + 1))
+ else
+ echo "[FAIL] $name ($url) -> HTTP $code (expected $expected)"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+echo "=== Global Stack Health Check ==="
+echo "Timestamp: $(date -Iseconds)"
+echo
+
+# Public Cloudflare Tunnel endpoints (status subdomain has no Access)
+check_url "status endpoint" "https://status.livesolutionsnow.com/healthz" "200"
+check_url "OpenProject login" "https://projects.livesolutionsnow.com/login" "200"
+check_url "OpenProject root" "https://projects.livesolutionsnow.com" "200"
+check_url "AI stack" "https://ai.livesolutionsnow.com" "200"
+check_url "n8n" "https://n8n.livesolutionsnow.com" "200"
+check_url "flows" "https://flows.livesolutionsnow.com" "200"
+check_url "comfy" "https://comfy.livesolutionsnow.com" "200"
+check_url "leads" "https://leads.livesolutionsnow.com" "200"
+check_url "qbo" "https://qbo.livesolutionsnow.com" "404"
+check_url "qbo-internal" "https://qbo-internal.livesolutionsnow.com" "200"
+check_url "design" "https://design.livesolutionsnow.com" "200"
+check_url "webhooks" "https://webhooks.livesolutionsnow.com" "200"
+
+echo
+echo "=== Local Docker Health ==="
+local_status=$(docker compose -f /home/anthonyturgman/openproject/docker-compose.yml -f /home/anthonyturgman/openproject/docker-compose.override.yml ps --format json 2>/dev/null || true)
+if [ -n "$local_status" ]; then
+ echo "$local_status" | python3 -c "
+import sys, json
+for line in sys.stdin:
+ try:
+ r = json.loads(line)
+ svc = r.get('Service', r.get('Name','unknown'))
+ health = r.get('Health', 'unknown')
+ state = r.get('State', 'unknown')
+ if health == 'healthy' or state == 'running':
+ print(f'[PASS] {svc} -> {health}/{state}')
+ else:
+ print(f'[FAIL] {svc} -> {health}/{state}')
+ except Exception:
+ pass
+" || echo "[WARN] Could not parse local compose health"
+else
+ echo "[WARN] Could not read local compose health"
+fi
+
+echo
+echo "=== Summary ==="
+echo "Passed: $PASS"
+echo "Failed: $FAIL"
+if [ "$FAIL" -eq 0 ]; then
+ echo "RESULT: ALL OK"
+ exit 0
+else
+ echo "RESULT: FAILURES DETECTED"
+ exit 1
+fi
diff --git a/scripts/health-check.sh b/scripts/health-check.sh
new file mode 100755
index 00000000..f9c7bf94
--- /dev/null
+++ b/scripts/health-check.sh
@@ -0,0 +1,190 @@
+#!/bin/bash
+# OpenProject post-upgrade / periodic health checks.
+# Verifies that data, formatting, style, and core integrations survive an upgrade.
+#
+# Usage:
+# ./scripts/health-check.sh
+
+set -e
+set -o pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+PUBLIC_URL="${OPENPROJECT_BASE_URL:-https://projects.livesolutionsnow.com}"
+INTERNAL_PROXY="http://proxy:80"
+FAILED=0
+
+log() {
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
+}
+
+warn() {
+ log "WARN: $*" >&2
+ FAILED=$((FAILED+1))
+}
+
+fail() {
+ log "FAIL: $*" >&2
+ FAILED=$((FAILED+1))
+}
+
+# ---------------------------------------------------------------------------
+# 1. Container health
+# ---------------------------------------------------------------------------
+log "Checking required containers are running/healthy..."
+required_services=(db web worker cron proxy relay hocuspocus)
+for svc in "${required_services[@]}"; do
+ container="$(docker compose ps -q "${svc}" 2>/dev/null || true)"
+ if [ -z "$container" ]; then
+ fail "Service ${svc} has no running container"
+ else
+ log "OK: ${svc} is running"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 2. Database connectivity and basic integrity
+# ---------------------------------------------------------------------------
+log "Checking database connectivity..."
+if docker compose exec -T db pg_isready -U postgres -d openproject > /dev/null 2>&1; then
+ log "OK: PostgreSQL is ready"
+else
+ fail "PostgreSQL is not ready"
+fi
+
+log "Checking database row counts..."
+user_count="$(docker compose exec -T db psql -U postgres -d openproject -At -c 'SELECT COUNT(*) FROM users;' 2>/dev/null || echo -1)"
+if [ "$user_count" -ge 0 ] 2>/dev/null; then
+ log "OK: users table readable (count=${user_count})"
+else
+ fail "Could not read users table"
+fi
+
+# ---------------------------------------------------------------------------
+# 3. Web health endpoint
+# ---------------------------------------------------------------------------
+log "Checking OpenProject health endpoint..."
+for i in $(seq 1 30); do
+ if docker compose exec -T web curl -fsS -H 'Host: projects.livesolutionsnow.com' -H 'X-Forwarded-Proto: https' http://localhost:8080/health_checks/default > /dev/null 2>&1; then
+ log "OK: OpenProject health endpoint returns success"
+ break
+ fi
+ if [ "$i" -eq 30 ]; then
+ fail "OpenProject health endpoint failed after 30 attempts"
+ fi
+ sleep 2
+done
+
+# ---------------------------------------------------------------------------
+# 4. Brand theme CSS is reachable
+# ---------------------------------------------------------------------------
+log "Checking brand theme CSS is served..."
+for i in $(seq 1 30); do
+ if docker compose exec -T web curl -fsS -H 'Host: projects.livesolutionsnow.com' -H 'X-Forwarded-Proto: https' "http://localhost:8080/stylesheets/livesolutions-theme.css" > /dev/null 2>&1; then
+ log "OK: livesolutions-theme.css is reachable"
+ break
+ fi
+ if [ "$i" -eq 30 ]; then
+ fail "livesolutions-theme.css is not reachable (brand formatting may be broken)"
+ fi
+ sleep 2
+done
+
+# ---------------------------------------------------------------------------
+# 5. Custom plugin path mounted
+# ---------------------------------------------------------------------------
+log "Checking custom plugin mount..."
+if docker compose exec -T web test -d /app/plugins/openproject-livesolutions; then
+ log "OK: openproject-livesolutions plugin directory mounted"
+else
+ fail "openproject-livesolutions plugin directory missing"
+fi
+
+# ---------------------------------------------------------------------------
+# 6. Cloudflare Access initializer present
+# ---------------------------------------------------------------------------
+log "Checking Cloudflare Access initializer..."
+if docker compose exec -T web test -f /app/config/initializers/custom/cloudflare_access_auth.rb; then
+ log "OK: cloudflare_access_auth.rb initializer present"
+else
+ fail "cloudflare_access_auth.rb initializer missing (SSO will break)"
+fi
+
+# ---------------------------------------------------------------------------
+# 7. Premium feature unlock initializer mounted
+# ---------------------------------------------------------------------------
+log "Checking premium feature unlock initializer..."
+if docker compose exec -T web test -f /app/config/initializers/custom/community_unlock.rb; then
+ log "OK: community_unlock.rb initializer present (premium features remain unlocked)"
+else
+ fail "community_unlock.rb initializer missing; premium features will re-lock"
+fi
+
+# ---------------------------------------------------------------------------
+# 7. Public site returns 200 and contains login form
+# ---------------------------------------------------------------------------
+log "Checking public login page via Cloudflare Tunnel..."
+if curl -fsS -L --max-time 30 "${PUBLIC_URL}/login" > /dev/null 2>&1; then
+ log "OK: public URL ${PUBLIC_URL}/login reachable"
+else
+ warn "Public URL ${PUBLIC_URL}/login not reachable (may be expected from this host)"
+fi
+
+# ---------------------------------------------------------------------------
+# 7a. Hierarchy collapse state migration applied
+# ---------------------------------------------------------------------------
+log "Checking hierarchy collapse state migration..."
+if docker compose exec -T web /app/bin/rails runner "exit(UserHierarchyCollapseState.table_exists? ? 0 : 1)" > /dev/null 2>&1; then
+ log "OK: user_hierarchy_collapse_states table exists"
+else
+ fail "user_hierarchy_collapse_states table missing (migration not applied)"
+fi
+
+# ---------------------------------------------------------------------------
+# 7b. Pagination default patch active (API default pageSize is 100)
+# ---------------------------------------------------------------------------
+log "Checking pagination default patch..."
+if docker compose exec -T web /app/bin/rails runner "
+ q = Query.new_default
+ h = API::V3::Queries::QueryParamsRepresenter.new(q).to_h
+ exit(h[:pageSize] == 100 ? 0 : 1)
+" > /dev/null 2>&1; then
+ log "OK: API default pageSize is 100"
+else
+ fail "API default pageSize is not 100 (pagination patch not active)"
+fi
+
+
+
+# ---------------------------------------------------------------------------
+# 8. Asset directory writable and not empty (after real use)
+# ---------------------------------------------------------------------------
+log "Checking asset directory..."
+asset_size="$(docker compose exec -T web du -sb /var/openproject/assets 2>/dev/null | cut -f1 || echo -1)"
+if [ "$asset_size" -ge 0 ] 2>/dev/null; then
+ log "OK: asset directory accessible (size=${asset_size} bytes)"
+else
+ warn "Could not determine asset directory size"
+fi
+
+# ---------------------------------------------------------------------------
+# 7c. Global status endpoint (status.livesolutionsnow.com/healthz)
+# ---------------------------------------------------------------------------
+log "Checking global status endpoint..."
+if curl -fsS -L --max-time 30 "https://status.livesolutionsnow.com/healthz" 2>/dev/null | grep -q "OK"; then
+ log "OK: https://status.livesolutionsnow.com/healthz returns OK"
+else
+ warn "Global status endpoint did not return OK"
+fi
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+if [ "$FAILED" -eq 0 ]; then
+ log "HEALTH CHECK PASSED"
+ exit 0
+else
+ log "HEALTH CHECK FAILED: ${FAILED} issue(s) detected"
+ exit 1
+fi
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
new file mode 100755
index 00000000..c1c25cff
--- /dev/null
+++ b/scripts/upgrade.sh
@@ -0,0 +1,248 @@
+#!/bin/bash
+# Hardened OpenProject upgrade orchestrator.
+#
+# Goals:
+# * Never lose data (backup before any mutation)
+# * Preserve formatting/style (custom plugin / CSS / initializer checks)
+# * Allow dry-run preview of upstream changes
+# * Roll back automatically if post-upgrade health checks fail
+#
+# Usage:
+# ./scripts/upgrade.sh [OPTIONS]
+#
+# Options:
+# --dry-run Fetch upstream changes, show diff, but do not modify anything.
+# --no-pull Skip `git pull` (useful when testing local changes).
+# --restore-only Only restore the most recent backup and restart (rollback mode).
+
+set -e
+set -o pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+COMPOSE_BASE="docker compose"
+CONTROL_COMPOSE="${COMPOSE_BASE} -f docker-compose.yml -f docker-compose.control.yml"
+DRY_RUN=false
+NO_PULL=false
+RESTORE_ONLY=false
+FAILED=false
+BACKUP_TIMESTAMP=""
+
+usage() {
+ echo "Usage: $(basename "$0") [--dry-run] [--no-pull] [--restore-only]"
+ exit 1
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --dry-run) DRY_RUN=true ;;
+ --no-pull) NO_PULL=true ;;
+ --restore-only) RESTORE_ONLY=true ;;
+ -h|--help) usage ;;
+ *) echo "Unknown option: $1" >&2; usage ;;
+ esac
+ shift
+done
+
+cd "$PROJECT_ROOT"
+
+log() {
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
+}
+
+fail() {
+ log "ERROR: $*" >&2
+ FAILED=true
+ exit 1
+}
+
+docker_compose_config_ok() {
+ log "Verifying Docker Compose configuration merge..."
+ if ! ${COMPOSE_BASE} config > /dev/null; then
+ fail "Merged compose configuration is invalid. Fix docker-compose.override.yml before continuing."
+ fi
+}
+
+pre_upgrade_backup() {
+ log "=== PRE-UPGRADE BACKUP ==="
+ ${CONTROL_COMPOSE} build || fail "Failed to build control plane"
+ ${CONTROL_COMPOSE} run --rm backup || fail "Backup failed"
+ BACKUP_TIMESTAMP="$(ls -1 backups | grep -E '^[0-9]+-manifest\.txt$' | sort -n | tail -1 | cut -d- -f1)"
+ if [ -z "$BACKUP_TIMESTAMP" ]; then
+ fail "Backup completed but no manifest was written"
+ fi
+ log "Backup timestamp: ${BACKUP_TIMESTAMP}"
+}
+
+pull_upstream() {
+ log "=== FETCH UPSTREAM CHANGES ==="
+ if [ "$NO_PULL" = true ]; then
+ log "Skipping git pull (--no-pull)."
+ return 0
+ fi
+
+ if [ "$DRY_RUN" = true ]; then
+ log "[DRY-RUN] Would run: git fetch origin stable/17"
+ git fetch origin stable/17 || fail "git fetch failed"
+ log "[DRY-RUN] Upstream diff preview:"
+ git diff HEAD...origin/stable/17 --stat || true
+ git diff HEAD...origin/stable/17 -- docker-compose.yml docker-compose.control.yml control/ .env.example README.md || true
+ return 0
+ fi
+
+ # Stash local customisations that are not in git before pulling, then re-apply.
+ # Only stash files that are tracked upstream so we don't stash our own custom dirs.
+ local stash_files=(docker-compose.override.yml proxy/Caddyfile .env)
+ local stashed=false
+ for f in "${stash_files[@]}"; do
+ if [ -f "$f" ] && ! git ls-files --error-unmatch "$f" > /dev/null 2>&1; then
+ log "Preserving local file: $f (will restore after pull)"
+ fi
+ done
+
+ # Store a copy of critical local files in /tmp in case git pull overwrites them.
+ cp docker-compose.override.yml /tmp/op-docker-compose.override.yml.pre-pull.$$
+ cp .env /tmp/op-env.pre-pull.$$
+
+ git fetch origin stable/17 || fail "git fetch failed"
+
+ # Try a clean merge first; if it fails, abort and leave repo untouched.
+ if ! git merge --no-commit --no-ff origin/stable/17; then
+ git merge --abort 2> /dev/null || true
+ cp /tmp/op-docker-compose.override.yml.pre-pull.$$ docker-compose.override.yml
+ cp /tmp/op-env.pre-pull.$$ .env
+ fail "Automatic merge of upstream changes failed. Resolve manually before re-running."
+ fi
+
+ # Restore local override files if upstream overwrote them.
+ if [ -f /tmp/op-docker-compose.override.yml.pre-pull.$$ ]; then
+ cp /tmp/op-docker-compose.override.yml.pre-pull.$$ docker-compose.override.yml
+ log "Restored docker-compose.override.yml after merge"
+ fi
+ if [ -f /tmp/op-env.pre-pull.$$ ]; then
+ cp /tmp/op-env.pre-pull.$$ .env
+ log "Restored .env after merge"
+ fi
+}
+
+pre_upgrade_feature_check() {
+ log "=== PRE-UPGRADE FEATURE / COMPATIBILITY CHECK ==="
+ ./scripts/feature-check.sh || fail "Pre-upgrade feature/compatibility check failed"
+}
+
+post_upgrade_feature_check() {
+ log "=== POST-UPGRADE FEATURE / COMPATIBILITY CHECK ==="
+ ./scripts/feature-check.sh || fail "Post-upgrade feature/compatibility check failed"
+}
+
+verify_custom_files() {
+ log "=== VERIFY CUSTOM FILES PRESENT ==="
+ local files=(
+ "custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css"
+ "custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec"
+ "openproject-config/initializers/cloudflare_access_auth.rb"
+ "openproject-config/initializers/community_unlock.rb"
+ "proxy/Caddyfile"
+ "smtp-relay/relay.py"
+ )
+ for f in "${files[@]}"; do
+ if [ ! -f "$f" ]; then
+ fail "Customisation missing after update: $f"
+ fi
+ log "OK: $f"
+ done
+}
+
+run_upgrade() {
+ if [ "$DRY_RUN" = true ]; then
+ log "[DRY-RUN] Would run database upgrade container if PG major version changed."
+ return 0
+ fi
+
+ log "=== DATABASE UPGRADE (if needed) ==="
+ # The upgrade script exits 0 when current PG version == target version.
+ ${CONTROL_COMPOSE} run --rm upgrade || fail "Database upgrade failed"
+}
+
+restart_stack() {
+ if [ "$DRY_RUN" = true ]; then
+ log "[DRY-RUN] Would run: docker compose up -d --build --pull always"
+ return 0
+ fi
+
+ log "=== RESTART STACK ==="
+ ${COMPOSE_BASE} down || true
+ ${COMPOSE_BASE} up -d --build --pull always || fail "Stack restart failed"
+}
+
+post_upgrade_health() {
+ if [ "$DRY_RUN" = true ]; then
+ log "[DRY-RUN] Would run post-upgrade health checks."
+ return 0
+ fi
+
+ log "=== POST-UPGRADE HEALTH CHECKS ==="
+ ./scripts/health-check.sh || fail "Post-upgrade health checks failed"
+}
+
+rollback() {
+ log "=== ROLLBACK ==="
+ if [ -z "$BACKUP_TIMESTAMP" ]; then
+ log "No backup timestamp recorded; attempting to restore latest backup."
+ fi
+ ${CONTROL_COMPOSE} down || true
+ RESTORE_TIMESTAMP="${BACKUP_TIMESTAMP}" ${CONTROL_COMPOSE} run --rm restore || fail "Rollback restore failed"
+ ${COMPOSE_BASE} up -d || fail "Rollback restart failed"
+ log "Rollback complete. Investigate before trying another upgrade."
+}
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+if [ "$RESTORE_ONLY" = true ]; then
+ log "=== RESTORE-ONLY MODE ==="
+ ${CONTROL_COMPOSE} down || true
+ ${CONTROL_COMPOSE} run --rm restore || fail "Restore failed"
+ ${COMPOSE_BASE} up -d || fail "Restart after restore failed"
+ ./scripts/health-check.sh
+ exit 0
+fi
+
+# Ensure all customisations are committed/stashed or at least present before mutating
+verify_custom_files
+docker_compose_config_ok
+pre_upgrade_feature_check
+pre_upgrade_backup
+
+# If anything after this point fails, attempt rollback.
+if ! pull_upstream; then
+ rollback
+ exit 1
+fi
+
+if ! run_upgrade; then
+ rollback
+ exit 1
+fi
+
+if ! restart_stack; then
+ rollback
+ exit 1
+fi
+
+if ! post_upgrade_health; then
+ rollback
+ exit 1
+fi
+
+if ! post_upgrade_feature_check; then
+ rollback
+ exit 1
+fi
+
+log "=== UPGRADE COMPLETE ==="
+if [ "$DRY_RUN" = true ]; then
+ log "Dry-run finished. No changes were made."
+else
+ log "OpenProject upgraded successfully."
+fi