Territory filtering was deferred from v1, so every account saw and could write every row. With the sales team about to adjust their own territories that is the thing standing in the way, and it is also why a rep would wait fifteen seconds to load 2.7M rows to work on a few thousand. The list lives on pf.app_user.territory with is_admin beside it, and col_meta.is_territory marks which column of a source the values belong to -- flagged rather than named in code, so a second source can be divided by something other than a sales rep. Fail closed: buildTerritoryClause returns FALSE for an empty list or an unflagged source. An account nobody configured sees nothing, rather than everything because a column was left null. Built from the session, never the request. That is what separates it from `scope`, which the browser sends and should: a filter the user chose belongs in the payload, a permission cannot come from the thing it restrains. It is ANDed on last, where nothing in the request can undo it. Enforced on /data (the cursor and the count behind X-Row-Count), on /agg before the GROUP BY since the territory column need not be in the grain, on every operation through sliceUnits, and on the value completion endpoint -- which reads the source table, so without it a dropdown enumerates every customer and rep in the business to someone shown none of their rows. Undo is gated by owner rather than territory: it removes an entry's rows wholesale, so half-undoing one would leave a state nothing describes. Recode refuses to set the territory column unless you are an admin, since moving a row between territories is reassignment, not forecasting. ./pf.sh gains set-territory, set-admin and orphan-territory. The last lists territory values no account owns -- work under one is invisible to everybody but an admin, which a typo causes easily and nothing in the app reveals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
641 lines
21 KiB
Bash
Executable File
641 lines
21 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pf.sh — Pivot Forecast management script
|
|
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
|
|
# ./pf.sh [add-user|passwd|list-users|disable-user|enable-user]
|
|
# ./pf.sh [set-territory|set-admin|orphan-territory]
|
|
# ./pf.sh (interactive menu)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SERVICE_NAME="pf_app"
|
|
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
ENV_FILE="${APP_DIR}/.env"
|
|
MIN_NODE_MAJOR=20
|
|
|
|
# -- Colors ------------------------------------------------------------------
|
|
R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; NC='\033[0m'
|
|
bold() { echo -e "\033[1m$*\033[0m"; }
|
|
info() { echo -e "${B}==>${NC} $*"; }
|
|
success() { echo -e "${G} ✓${NC} $*"; }
|
|
warn() { echo -e "${Y} !${NC} $*"; }
|
|
error() { echo -e "${R} ✗${NC} $*" >&2; }
|
|
die() { error "$*"; exit 1; }
|
|
|
|
# -- Helpers -----------------------------------------------------------------
|
|
|
|
require_systemd() {
|
|
systemctl --version &>/dev/null || die "systemd not found on this system."
|
|
}
|
|
|
|
node_binary() {
|
|
command -v node 2>/dev/null || true
|
|
}
|
|
|
|
check_node() {
|
|
local node
|
|
node=$(node_binary)
|
|
[[ -z "$node" ]] && die "node not found. Install Node.js >= ${MIN_NODE_MAJOR}."
|
|
local ver
|
|
ver=$(node --version | sed 's/v//')
|
|
local major="${ver%%.*}"
|
|
if (( major < MIN_NODE_MAJOR )); then
|
|
die "Node.js ${ver} found; requires >= ${MIN_NODE_MAJOR}. Please upgrade."
|
|
fi
|
|
success "Node.js ${ver}"
|
|
}
|
|
|
|
require_env() {
|
|
[[ -f "$ENV_FILE" ]] || die ".env not found. Run: ./pf.sh config"
|
|
}
|
|
|
|
load_env() {
|
|
require_env
|
|
set -a; source "$ENV_FILE"; set +a
|
|
}
|
|
|
|
sudo_if_needed() {
|
|
# Returns "sudo" if we're not root, empty string if we are
|
|
[[ "$EUID" -eq 0 ]] && echo "" || echo "sudo"
|
|
}
|
|
|
|
service_installed() {
|
|
[[ -f "$SERVICE_FILE" ]]
|
|
}
|
|
|
|
require_service() {
|
|
service_installed || die "systemd service not installed. Run: ./pf.sh install-service"
|
|
}
|
|
|
|
# The keys cmd_config manages; anything else in .env is left alone.
|
|
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT SESSION_SECRET COOKIE_SECURE)
|
|
|
|
env_get() {
|
|
[[ -f "$ENV_FILE" ]] || return 0
|
|
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' || true
|
|
}
|
|
|
|
# psql against the DB_* connection in .env; extra args are passed through.
|
|
run_psql() {
|
|
PGPASSWORD="${DB_PASSWORD:-}" psql \
|
|
-h "${DB_HOST:-localhost}" \
|
|
-p "${DB_PORT:-5432}" \
|
|
-U "${DB_USER}" \
|
|
-d "${DB_NAME}" \
|
|
"$@"
|
|
}
|
|
|
|
db_ping() {
|
|
load_env
|
|
if [[ -z "${DB_NAME:-}" || -z "${DB_USER:-}" ]]; then
|
|
warn "DB_NAME / DB_USER not set in .env"
|
|
return 1
|
|
fi
|
|
# Use psql if available for a real connectivity check
|
|
if command -v psql &>/dev/null; then
|
|
run_psql -tAc "SELECT 1" &>/dev/null && return 0 || return 1
|
|
else
|
|
warn "psql not in PATH — skipping live DB check"
|
|
return 0
|
|
fi
|
|
}
|
|
|
|
# -- Commands ----------------------------------------------------------------
|
|
|
|
cmd_deploy() {
|
|
echo; bold "Deploying Pivot Forecast"
|
|
echo " App dir: $APP_DIR"
|
|
echo
|
|
|
|
check_node
|
|
require_env
|
|
|
|
info "Pulling latest from git…"
|
|
git -C "$APP_DIR" pull
|
|
|
|
info "Installing server dependencies…"
|
|
npm --prefix "$APP_DIR" install --omit=dev
|
|
|
|
info "Installing UI dependencies…"
|
|
npm --prefix "$APP_DIR/ui" install
|
|
|
|
info "Building UI…"
|
|
npm --prefix "$APP_DIR/ui" run build
|
|
|
|
if service_installed; then
|
|
info "Restarting service…"
|
|
cmd_restart
|
|
else
|
|
warn "Service not installed — server not started."
|
|
echo " Run: ./pf.sh install-service"
|
|
fi
|
|
|
|
success "Deploy complete."
|
|
}
|
|
|
|
cmd_start() {
|
|
require_systemd; require_service
|
|
info "Starting ${SERVICE_NAME}…"
|
|
$(sudo_if_needed) systemctl start "$SERVICE_NAME"
|
|
success "Started."
|
|
}
|
|
|
|
cmd_stop() {
|
|
require_systemd; require_service
|
|
info "Stopping ${SERVICE_NAME}…"
|
|
$(sudo_if_needed) systemctl stop "$SERVICE_NAME"
|
|
success "Stopped."
|
|
}
|
|
|
|
cmd_restart() {
|
|
require_systemd; require_service
|
|
info "Restarting ${SERVICE_NAME}…"
|
|
$(sudo_if_needed) systemctl restart "$SERVICE_NAME"
|
|
success "Restarted."
|
|
}
|
|
|
|
cmd_status() {
|
|
require_systemd
|
|
echo
|
|
bold "System service"
|
|
if service_installed; then
|
|
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
|
else
|
|
warn "Service not installed — run: ./pf.sh install-service"
|
|
fi
|
|
|
|
echo
|
|
bold "Database"
|
|
if db_ping; then
|
|
success "DB reachable"
|
|
else
|
|
error "DB not reachable (check DATABASE_URL in .env)"
|
|
fi
|
|
|
|
echo
|
|
bold "Git"
|
|
git -C "$APP_DIR" log -1 --format=" Commit: %h %s (%ar)"
|
|
local branch
|
|
branch=$(git -C "$APP_DIR" rev-parse --abbrev-ref HEAD)
|
|
echo " Branch: $branch"
|
|
}
|
|
|
|
cmd_logs() {
|
|
require_systemd; require_service
|
|
info "Streaming logs (Ctrl-C to exit)…"
|
|
journalctl -u "$SERVICE_NAME" -f --no-pager
|
|
}
|
|
|
|
cmd_db_setup() {
|
|
require_env; load_env
|
|
[[ -n "${DB_NAME:-}" && -n "${DB_USER:-}" ]] || die "DB_NAME / DB_USER not set in .env — run: ./pf.sh config"
|
|
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
|
|
|
echo
|
|
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
|
|
warn "This creates the pf schema, tables, and the account/session tables."
|
|
warn "Safe to re-run (CREATE IF NOT EXISTS)."
|
|
read -rp " Continue? [y/N] " confirm
|
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
|
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
|
|
success "Schema applied."
|
|
|
|
local n
|
|
n=$(run_psql -tAc "SELECT count(*) FROM pf.app_user WHERE is_active" 2>/dev/null || echo 0)
|
|
if [[ "${n:-0}" == "0" ]]; then
|
|
warn "No active accounts yet — create one with: ./pf.sh add-user"
|
|
fi
|
|
}
|
|
|
|
cmd_config() {
|
|
echo
|
|
bold "Configure .env"
|
|
echo " File: $ENV_FILE"
|
|
echo
|
|
|
|
local cur_host cur_port cur_name cur_user cur_pass cur_app_port
|
|
cur_host=$(env_get DB_HOST)
|
|
cur_port=$(env_get DB_PORT)
|
|
cur_name=$(env_get DB_NAME)
|
|
cur_user=$(env_get DB_USER)
|
|
cur_pass=$(env_get DB_PASSWORD)
|
|
cur_app_port=$(env_get PORT)
|
|
|
|
local input
|
|
read -rp " DB_HOST [${cur_host:-localhost}]: " input
|
|
local host="${input:-${cur_host:-localhost}}"
|
|
|
|
read -rp " DB_PORT [${cur_port:-5432}]: " input
|
|
local port="${input:-${cur_port:-5432}}"
|
|
|
|
read -rp " DB_NAME [${cur_name:-not set}]: " input
|
|
local name="${input:-$cur_name}"
|
|
[[ -z "$name" ]] && die "DB_NAME is required."
|
|
|
|
read -rp " DB_USER [${cur_user:-$USER}]: " input
|
|
local user="${input:-${cur_user:-$USER}}"
|
|
|
|
if [[ -n "$cur_pass" ]]; then
|
|
read -rsp " DB_PASSWORD [keep existing]: " input; echo
|
|
else
|
|
read -rsp " DB_PASSWORD: " input; echo
|
|
fi
|
|
local pass="${input:-$cur_pass}"
|
|
|
|
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
|
|
local app_port="${input:-${cur_app_port:-3010}}"
|
|
|
|
# Session cookies are signed with this; regenerating it signs everyone out,
|
|
# so an existing secret is kept rather than re-rolled on every config run.
|
|
local secret
|
|
secret=$(env_get SESSION_SECRET)
|
|
if [[ -z "$secret" ]]; then
|
|
secret=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
|
|
success "SESSION_SECRET generated."
|
|
else
|
|
success "SESSION_SECRET kept (delete the line in .env to re-roll)."
|
|
fi
|
|
|
|
local cur_secure
|
|
cur_secure=$(env_get COOKIE_SECURE)
|
|
read -rp " COOKIE_SECURE — HTTPS-only cookie [${cur_secure:-true}]: " input
|
|
local cookie_secure="${input:-${cur_secure:-true}}"
|
|
|
|
# Rewrite the managed keys, carrying over any other lines already in .env.
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
cat > "$tmp" <<EOF
|
|
DB_HOST=${host}
|
|
DB_PORT=${port}
|
|
DB_NAME=${name}
|
|
DB_USER=${user}
|
|
DB_PASSWORD=${pass}
|
|
PORT=${app_port}
|
|
SESSION_SECRET=${secret}
|
|
COOKIE_SECURE=${cookie_secure}
|
|
EOF
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
local managed
|
|
managed=$(IFS='|'; echo "${ENV_KEYS[*]}")
|
|
grep -vE "^(${managed})=" "$ENV_FILE" | grep -vE '^[[:space:]]*$' >> "$tmp" || true
|
|
fi
|
|
mv "$tmp" "$ENV_FILE"
|
|
chmod 600 "$ENV_FILE"
|
|
success ".env written."
|
|
|
|
if db_ping; then
|
|
success "Database connection verified."
|
|
else
|
|
warn "Could not reach the database — double-check the DB_* settings."
|
|
fi
|
|
}
|
|
|
|
# -- Accounts ----------------------------------------------------------------
|
|
|
|
# Reads a password twice without echo and hashes it with lib/auth.js, so the
|
|
# plaintext never reaches argv, psql, or the shell history.
|
|
read_new_password() {
|
|
local p1 p2
|
|
# Prompts and their newlines go to stderr: stdout is the hash, and a stray
|
|
# newline there ends up prefixed to it by the caller's $( ).
|
|
read -rsp " Password: " p1; echo >&2
|
|
[[ -z "$p1" ]] && { error "Password cannot be empty."; return 1; }
|
|
read -rsp " Confirm : " p2; echo >&2
|
|
[[ "$p1" != "$p2" ]] && { error "Passwords do not match."; return 1; }
|
|
printf '%s' "$p1" | node "${APP_DIR}/lib/auth.js" hash
|
|
}
|
|
|
|
# psql single-quoted literal: double any embedded quote.
|
|
sql_lit() { printf "%s" "${1//\'/\'\'}"; }
|
|
|
|
cmd_add_user() {
|
|
require_env; load_env
|
|
check_node >/dev/null
|
|
|
|
echo; bold "Add account"
|
|
local username display hash
|
|
read -rp " Username: " username
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
read -rp " Display name [${username}]: " display
|
|
display="${display:-$username}"
|
|
|
|
hash=$(read_new_password) || return 1
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH ins AS (
|
|
INSERT INTO pf.app_user (username, pass_hash, display_name)
|
|
VALUES ('$(sql_lit "$username")', '$(sql_lit "$hash")', '$(sql_lit "$display")')
|
|
ON CONFLICT (username) DO NOTHING
|
|
RETURNING id
|
|
) SELECT id FROM ins" | grep -q . \
|
|
&& success "Account '${username}' created." \
|
|
|| die "Account '${username}' already exists — change its password with: ./pf.sh passwd"
|
|
}
|
|
|
|
cmd_passwd() {
|
|
require_env; load_env
|
|
check_node >/dev/null
|
|
|
|
echo; bold "Change password"
|
|
local username hash
|
|
read -rp " Username: " username
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
|
|
hash=$(read_new_password) || return 1
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET pass_hash = '$(sql_lit "$hash")'
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING id
|
|
) SELECT id FROM upd" | grep -q . \
|
|
&& success "Password updated for '${username}'." \
|
|
|| die "No such account: ${username}"
|
|
}
|
|
|
|
cmd_list_users() {
|
|
require_env; load_env
|
|
echo; bold "Accounts"
|
|
run_psql -c "
|
|
SELECT username, display_name, is_active, is_admin,
|
|
coalesce(jsonb_array_length(territory), 0) AS territory_values,
|
|
to_char(last_login_at, 'YYYY-MM-DD HH24:MI') AS last_login
|
|
FROM pf.app_user ORDER BY username"
|
|
}
|
|
|
|
# Territory is what an account may see and change, as a list of values in the
|
|
# source's is_territory column. No territory and not an admin means no rows --
|
|
# so a new account is blind until this is run, which is the intended direction
|
|
# to fail in.
|
|
#
|
|
# Values are given comma-separated and have to match the column exactly, since
|
|
# that is what the SQL compares. set-territory with no values clears it.
|
|
cmd_set_territory() {
|
|
require_env; load_env
|
|
local username="${1:-}"; shift || true
|
|
[[ -z "$username" ]] && { read -rp " Username: " username; }
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
local values="${*:-}"
|
|
[[ -z "$values" ]] && { read -rp " Territory values (comma separated, blank to clear): " values; }
|
|
|
|
local json="null"
|
|
if [[ -n "$values" ]]; then
|
|
json=$(python3 - "$values" <<'PYEOF'
|
|
import json, sys
|
|
vals = [v.strip() for v in sys.argv[1].split(',') if v.strip()]
|
|
print(json.dumps(vals))
|
|
PYEOF
|
|
)
|
|
fi
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET territory = $(if [[ "$json" == "null" ]]; then echo NULL; else echo "'$(sql_lit "$json")'::jsonb"; fi)
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING username
|
|
)
|
|
SELECT count(*) FROM upd" | grep -q '^1$' \
|
|
|| die "No such account: $username"
|
|
ok "Territory updated for $username"
|
|
run_psql -c "SELECT username, is_admin, territory FROM pf.app_user WHERE lower(username) = lower('$(sql_lit "$username")')"
|
|
}
|
|
|
|
# An admin sees and changes everything, and is the only account that can recode
|
|
# the territory column or undo someone else's entry.
|
|
cmd_set_admin() {
|
|
require_env; load_env
|
|
local username="${1:-}" flag="${2:-true}"
|
|
[[ -z "$username" ]] && { read -rp " Username: " username; }
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
[[ "$flag" != "true" && "$flag" != "false" ]] && die "Second argument must be true or false."
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET is_admin = $flag
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING username
|
|
)
|
|
SELECT count(*) FROM upd" | grep -q '^1$' \
|
|
|| die "No such account: $username"
|
|
ok "$username is_admin = $flag"
|
|
}
|
|
|
|
# Territory values present in the data that belong to no account. Work under one
|
|
# is invisible to everybody but an admin, which is easy to cause by a typo and
|
|
# impossible to notice from inside the app.
|
|
cmd_orphan_territory() {
|
|
require_env; load_env
|
|
local source_id="${1:-}"
|
|
[[ -z "$source_id" ]] && { read -rp " Source id: " source_id; }
|
|
[[ -z "$source_id" ]] && die "Source id is required."
|
|
|
|
# The column and table are data, so the query is built in two steps rather
|
|
# than one clever one: read the names, then run the listing.
|
|
local meta col schema tname
|
|
meta=$(run_psql -tAF'|' -c "
|
|
SELECT m.cname, x.schema, x.tname
|
|
FROM pf.col_meta m JOIN pf.source x ON x.id = m.source_id
|
|
WHERE m.source_id = $source_id AND m.is_territory")
|
|
[[ -z "$meta" ]] && die "Source $source_id has no column marked is_territory."
|
|
IFS='|' read -r col schema tname <<< "$meta"
|
|
|
|
echo; bold "Territory values in $schema.$tname with no account"
|
|
run_psql -c "
|
|
SELECT DISTINCT s.\"$col\" AS unassigned
|
|
FROM \"$schema\".\"$tname\" s
|
|
WHERE TRUE
|
|
AND s.\"$col\" IS NOT NULL
|
|
AND s.\"$col\"::text NOT IN (
|
|
SELECT jsonb_array_elements_text(territory)
|
|
FROM pf.app_user
|
|
WHERE territory IS NOT NULL
|
|
)
|
|
ORDER BY 1"
|
|
}
|
|
|
|
# Deactivating leaves the row (and its history) in place, and drops any live
|
|
# session so the account loses access immediately rather than at cookie expiry.
|
|
cmd_disable_user() {
|
|
require_env; load_env
|
|
local username="${1:-}"
|
|
[[ -z "$username" ]] && { read -rp " Username to disable: " username; }
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET is_active = false
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING id
|
|
) SELECT id FROM upd" | grep -q . \
|
|
|| die "No such account: ${username}"
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -c "
|
|
DELETE FROM pf.session
|
|
WHERE sess::jsonb -> 'user' ->> 'username' ILIKE '$(sql_lit "$username")'" >/dev/null
|
|
success "Account '${username}' disabled and signed out."
|
|
}
|
|
|
|
cmd_enable_user() {
|
|
require_env; load_env
|
|
local username="${1:-}"
|
|
[[ -z "$username" ]] && { read -rp " Username to enable: " username; }
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET is_active = true
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING id
|
|
) SELECT id FROM upd" | grep -q . \
|
|
&& success "Account '${username}' enabled." \
|
|
|| die "No such account: ${username}"
|
|
}
|
|
|
|
cmd_install_service() {
|
|
require_systemd
|
|
require_env
|
|
|
|
local node_path
|
|
node_path=$(node_binary)
|
|
[[ -z "$node_path" ]] && die "node not found — install Node.js first."
|
|
|
|
local run_user="$USER"
|
|
local s
|
|
s=$(sudo_if_needed)
|
|
|
|
echo
|
|
bold "Install systemd service"
|
|
echo " Service file : $SERVICE_FILE"
|
|
echo " Run as user : $run_user"
|
|
echo " App dir : $APP_DIR"
|
|
echo " Node binary : $node_path"
|
|
echo
|
|
read -rp " Continue? [y/N] " confirm
|
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
|
|
|
$s tee "$SERVICE_FILE" > /dev/null <<EOF
|
|
[Unit]
|
|
Description=Pivot Forecast App
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${run_user}
|
|
WorkingDirectory=${APP_DIR}
|
|
EnvironmentFile=${ENV_FILE}
|
|
ExecStart=${node_path} ${APP_DIR}/server.js
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=${SERVICE_NAME}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
$s systemctl daemon-reload
|
|
$s systemctl enable "$SERVICE_NAME"
|
|
success "Service installed and enabled."
|
|
echo " Start now with: ./pf.sh start"
|
|
}
|
|
|
|
cmd_uninstall_service() {
|
|
require_systemd
|
|
service_installed || { warn "Service not installed."; return; }
|
|
|
|
echo
|
|
warn "This will stop and remove the systemd service (does not touch app files)."
|
|
read -rp " Continue? [y/N] " confirm
|
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
|
|
|
local s
|
|
s=$(sudo_if_needed)
|
|
$s systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
|
$s systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
|
$s rm -f "$SERVICE_FILE"
|
|
$s systemctl daemon-reload
|
|
success "Service removed."
|
|
}
|
|
|
|
# -- Interactive menu --------------------------------------------------------
|
|
|
|
interactive_menu() {
|
|
while true; do
|
|
echo
|
|
bold "Pivot Forecast — Management"
|
|
echo " 1) deploy pull + install + build + restart"
|
|
echo " 2) start start server"
|
|
echo " 3) stop stop server"
|
|
echo " 4) restart restart server"
|
|
echo " 5) status service + DB + git info"
|
|
echo " 6) logs tail journald logs"
|
|
echo " 7) db-setup apply setup_sql/01_schema.sql"
|
|
echo " 8) config set DB connection + app PORT"
|
|
echo " 9) install-service create systemd unit file"
|
|
echo " 10) uninstall-service remove systemd unit file"
|
|
echo " 11) add-user create a login account"
|
|
echo " 12) passwd change an account password"
|
|
echo " 13) list-users show accounts"
|
|
echo " 16) set-territory grant an account its territory values"
|
|
echo " 17) set-admin make an account an administrator"
|
|
echo " 18) orphan-territory territory values no account owns"
|
|
echo " 14) disable-user deactivate an account and sign it out"
|
|
echo " 15) enable-user reactivate an account"
|
|
echo " q) quit"
|
|
echo
|
|
read -rp " Choice: " choice
|
|
case "$choice" in
|
|
1|deploy) cmd_deploy ;;
|
|
2|start) cmd_start ;;
|
|
3|stop) cmd_stop ;;
|
|
4|restart) cmd_restart ;;
|
|
5|status) cmd_status ;;
|
|
6|logs) cmd_logs ;;
|
|
7|db-setup) cmd_db_setup ;;
|
|
8|config) cmd_config ;;
|
|
9|install-service) cmd_install_service ;;
|
|
10|uninstall-service) cmd_uninstall_service ;;
|
|
11|add-user) cmd_add_user ;;
|
|
12|passwd) cmd_passwd ;;
|
|
13|list-users) cmd_list_users ;;
|
|
16|set-territory) cmd_set_territory ;;
|
|
17|set-admin) cmd_set_admin ;;
|
|
18|orphan-territory) cmd_orphan_territory ;;
|
|
14|disable-user) cmd_disable_user ;;
|
|
15|enable-user) cmd_enable_user ;;
|
|
q|Q|quit|exit) echo "Bye."; exit 0 ;;
|
|
*) warn "Unknown option: $choice" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# -- Dispatch ----------------------------------------------------------------
|
|
|
|
case "${1:-}" in
|
|
deploy) cmd_deploy ;;
|
|
start) cmd_start ;;
|
|
stop) cmd_stop ;;
|
|
restart) cmd_restart ;;
|
|
status) cmd_status ;;
|
|
logs) cmd_logs ;;
|
|
db-setup) cmd_db_setup ;;
|
|
config) cmd_config ;;
|
|
install-service) cmd_install_service ;;
|
|
uninstall-service) cmd_uninstall_service ;;
|
|
add-user) cmd_add_user ;;
|
|
passwd) cmd_passwd ;;
|
|
list-users) cmd_list_users ;;
|
|
set-territory) shift; cmd_set_territory "$@" ;;
|
|
set-admin) shift; cmd_set_admin "$@" ;;
|
|
orphan-territory) shift; cmd_orphan_territory "$@" ;;
|
|
disable-user) cmd_disable_user "${2:-}" ;;
|
|
enable-user) cmd_enable_user "${2:-}" ;;
|
|
"") interactive_menu ;;
|
|
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service add-user passwd list-users disable-user enable-user set-territory set-admin orphan-territory" ;;
|
|
esac
|