Build Scripts (ready to run)
All scripts are in /jfmsrv01-build/ on the server. Run in order.
/jfmsrv01-build/01_storage_layout.sh
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Phase 2
# Storage Layout Script
#
# Run as root after 00_provision_server.sh has completed.
# Creates the full data folder structure, sets permissions, mounts the data
# disk, and configures the temp/processing cleanup job.
#
# Usage:
# chmod +x 01_storage_layout.sh
# sudo bash 01_storage_layout.sh
# =============================================================================
set -euo pipefail
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
APP_USER="jfmsrv01"
APP_GROUP="jfmsrv01"
# Data disk device - set this to your actual data disk.
# Run 'lsblk' to identify the device. Common values:
# /dev/sdb (second SCSI disk)
# /dev/vdb (KVM/QEMU virtual disk)
# /dev/nvme1n1 (second NVMe disk)
# Set to "" to skip disk mounting (use if data disk is already mounted
# or if running on a single-disk development VM).
DATA_DISK_DEVICE="/dev/sdb"
# Mount point for the data disk
DATA_MOUNT="/data"
# Base paths
DATA_BASE="/data/jfmsrv01"
BACKUP_BASE="/backups/jfmsrv01"
APP_BASE="/opt/jfmsrv01"
# Temp file cleanup thresholds (hours)
TEMP_MAX_AGE_HOURS=24
PROCESSING_MAX_AGE_HOURS=2
IMPORTS_MAX_AGE_HOURS=1
# Storage quota defaults (GB) - applied to new firms
DEFAULT_QUOTA_GB=50
QUOTA_WARN_PERCENT=80
# -----------------------------------------------------------------------------
# Colour helpers
# -----------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "This script must be run as root."
id "$APP_USER" &>/dev/null || error "App user '$APP_USER' not found. Run 00_provision_server.sh first."
info "Starting storage layout setup"
info "Data base: $DATA_BASE | Backup base: $BACKUP_BASE"
# =============================================================================
# Mount data disk
# =============================================================================
if [[ -n "$DATA_DISK_DEVICE" ]]; then
if ! mountpoint -q "$DATA_MOUNT"; then
info "Setting up data disk: $DATA_DISK_DEVICE -> $DATA_MOUNT"
if [[ ! -b "$DATA_DISK_DEVICE" ]]; then
warn "Device $DATA_DISK_DEVICE not found."
warn "Skipping disk mount. Using OS disk for data (not recommended for production)."
warn "To mount later: mkfs.ext4 $DATA_DISK_DEVICE && mount $DATA_DISK_DEVICE $DATA_MOUNT"
else
# Check if already formatted
FS_TYPE=$(blkid -o value -s TYPE "$DATA_DISK_DEVICE" 2>/dev/null || echo "")
if [[ -z "$FS_TYPE" ]]; then
info "Formatting $DATA_DISK_DEVICE as ext4"
mkfs.ext4 -L jfmsrv01-data "$DATA_DISK_DEVICE"
success "Disk formatted"
else
info "Disk already formatted as $FS_TYPE"
fi
mkdir -p "$DATA_MOUNT"
mount "$DATA_DISK_DEVICE" "$DATA_MOUNT"
# Add to fstab for persistence
DISK_UUID=$(blkid -o value -s UUID "$DATA_DISK_DEVICE")
if ! grep -q "$DISK_UUID" /etc/fstab; then
echo "UUID=$DISK_UUID $DATA_MOUNT ext4 defaults,noatime 0 2" >> /etc/fstab
info "Added to /etc/fstab (UUID: $DISK_UUID)"
fi
success "Data disk mounted at $DATA_MOUNT"
fi
else
success "Data disk already mounted at $DATA_MOUNT"
fi
else
info "DATA_DISK_DEVICE not set - using OS disk for data storage"
warn "For production, attach and configure a separate data disk"
fi
# =============================================================================
# Create directory structure
# =============================================================================
info "Creating application directory structure"
# Application directories
mkdir -p "$APP_BASE/app"
mkdir -p "$APP_BASE/scripts"
mkdir -p "$APP_BASE/workers"
# Main data tree
mkdir -p "$DATA_BASE/firms"
mkdir -p "$DATA_BASE/shared"
mkdir -p "$DATA_BASE/quarantine"
mkdir -p "$DATA_BASE/system"
mkdir -p "$DATA_BASE/imports"
mkdir -p "$DATA_BASE/processing"
# Backup tree
mkdir -p "$BACKUP_BASE/source"
mkdir -p "$BACKUP_BASE/database"
mkdir -p "$BACKUP_BASE/files"
mkdir -p "$BACKUP_BASE/tenants"
mkdir -p "$BACKUP_BASE/handovers"
mkdir -p "$BACKUP_BASE/reports"
mkdir -p "$BACKUP_BASE/restore-tests"
success "Directory structure created"
# =============================================================================
# Permissions
# =============================================================================
info "Setting ownership and permissions"
# Application directories
chown -R "$APP_USER:$APP_GROUP" "$APP_BASE"
chmod 750 "$APP_BASE"
chmod 750 "$APP_BASE/app"
chmod 750 "$APP_BASE/scripts"
chmod 750 "$APP_BASE/workers"
# Data directories
chown -R "$APP_USER:$APP_GROUP" "$DATA_BASE"
chmod 750 "$DATA_BASE"
chmod 750 "$DATA_BASE/firms"
chmod 750 "$DATA_BASE/shared"
chmod 750 "$DATA_BASE/quarantine"
chmod 750 "$DATA_BASE/system"
chmod 750 "$DATA_BASE/imports"
chmod 750 "$DATA_BASE/processing"
# Backup directories
chown -R "$APP_USER:$APP_GROUP" "$BACKUP_BASE"
chmod 750 "$BACKUP_BASE"
find "$BACKUP_BASE" -type d -exec chmod 750 {} \;
success "Permissions set (750, owned by $APP_USER)"
# Verify web server cannot access data
WEB_USER="www-data"
if id "$WEB_USER" &>/dev/null; then
# Test that www-data cannot read the data directory
if sudo -u "$WEB_USER" ls "$DATA_BASE" &>/dev/null; then
warn "www-data can read $DATA_BASE - tightening permissions"
chmod 700 "$DATA_BASE"
else
success "Confirmed: $WEB_USER cannot read $DATA_BASE"
fi
fi
# =============================================================================
# Folder README files
# =============================================================================
info "Creating folder README files"
cat > "$DATA_BASE/shared/README.txt" <<'EOF'
shared/
-------
Platform-level assets that are not firm-specific.
May include global template starters and platform branding assets.
Must not contain any firm data, recordings, or documents.
EOF
cat > "$DATA_BASE/quarantine/README.txt" <<'EOF'
quarantine/
-----------
Files that have failed virus/malware scanning or format validation.
Files are moved here rather than deleted so they can be reviewed by a
platform admin before permanent removal.
Restricted to platform admin access only.
DO NOT process or open files here without first understanding why they
were quarantined.
EOF
cat > "$DATA_BASE/imports/README.txt" <<'EOF'
imports/
--------
Staging area for files arriving from external connectors (SMB, SFTP, FTP,
local agent) before they have been validated and assigned to a firm.
Files should not remain here after processing completes.
Stale files here indicate a failed or stuck import job.
EOF
cat > "$DATA_BASE/processing/README.txt" <<'EOF'
processing/
-----------
Temporary working area for files actively being processed by a queue worker.
A file should only exist here while a worker job is actively working on it.
Stale files here indicate a crashed or stuck worker.
The cleanup job will flag files older than the configured threshold.
EOF
chown -R "$APP_USER:$APP_GROUP" "$DATA_BASE"
success "README files created"
# =============================================================================
# Create firm directory helper function (for use during firm onboarding)
# =============================================================================
FIRM_SETUP_SCRIPT="$APP_BASE/scripts/create_firm_storage.sh"
cat > "$FIRM_SETUP_SCRIPT" <<'FIRMSCRIPT'
#!/usr/bin/env bash
# Usage: create_firm_storage.sh <firm_id>
# Creates the storage directory tree for a new firm.
set -euo pipefail
APP_USER="jfmsrv01"
DATA_BASE="/data/jfmsrv01"
FIRM_ID="${1:-}"
[[ -z "$FIRM_ID" ]] && { echo "Usage: $0 <firm_id>"; exit 1; }
FIRM_DIR="$DATA_BASE/firms/$FIRM_ID"
if [[ -d "$FIRM_DIR" ]]; then
echo "Firm directory already exists: $FIRM_DIR"
exit 0
fi
echo "Creating storage for firm: $FIRM_ID"
mkdir -p "$FIRM_DIR/recordings"
mkdir -p "$FIRM_DIR/transcripts"
mkdir -p "$FIRM_DIR/documents"
mkdir -p "$FIRM_DIR/templates"
mkdir -p "$FIRM_DIR/attachments"
mkdir -p "$FIRM_DIR/exports"
mkdir -p "$FIRM_DIR/temp"
mkdir -p "$FIRM_DIR/reports"
mkdir -p "$FIRM_DIR/backups"
chown -R "$APP_USER:$APP_USER" "$FIRM_DIR"
chmod -R 750 "$FIRM_DIR"
# Create firm backup directory
mkdir -p "/backups/jfmsrv01/tenants/$FIRM_ID"
chown -R "$APP_USER:$APP_USER" "/backups/jfmsrv01/tenants/$FIRM_ID"
chmod 750 "/backups/jfmsrv01/tenants/$FIRM_ID"
echo "Storage created: $FIRM_DIR"
echo " recordings/ transcripts/ documents/ templates/"
echo " attachments/ exports/ temp/ reports/"
echo " backups/"
FIRMSCRIPT
chmod 750 "$FIRM_SETUP_SCRIPT"
chown "$APP_USER:$APP_GROUP" "$FIRM_SETUP_SCRIPT"
success "Firm storage helper script created: $FIRM_SETUP_SCRIPT"
# =============================================================================
# Cleanup job
# =============================================================================
info "Creating temp/processing cleanup job"
CLEANUP_SCRIPT="$APP_BASE/scripts/cleanup_temp.sh"
cat > "$CLEANUP_SCRIPT" <<CLEANSCRIPT
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Temp/Processing Cleanup Job
# Runs on a schedule via cron. Cleans stale temp and processing files.
# Safe to run frequently - only removes files older than configured thresholds.
# =============================================================================
set -euo pipefail
DATA_BASE="$DATA_BASE"
LOG_FILE="/var/log/jfmsrv01_cleanup.log"
TEMP_MAX_AGE=$TEMP_MAX_AGE_HOURS
PROCESSING_MAX_AGE=$PROCESSING_MAX_AGE_HOURS
IMPORTS_MAX_AGE=$IMPORTS_MAX_AGE_HOURS
log() { echo "\$(date '+%Y-%m-%d %H:%M:%S') [CLEANUP] \$*" | tee -a "\$LOG_FILE"; }
log "Starting cleanup job"
# Clean per-firm temp folders
TEMP_CLEANED=0
while IFS= read -r -d '' FIRM_DIR; do
TEMP_DIR="\$FIRM_DIR/temp"
if [[ -d "\$TEMP_DIR" ]]; then
COUNT=\$(find "\$TEMP_DIR" -type f -mmin +\$(( TEMP_MAX_AGE * 60 )) 2>/dev/null | wc -l)
if [[ \$COUNT -gt 0 ]]; then
find "\$TEMP_DIR" -type f -mmin +\$(( TEMP_MAX_AGE * 60 )) -delete
log "Cleaned \$COUNT temp files from \$(basename \$FIRM_DIR)/temp"
TEMP_CLEANED=\$(( TEMP_CLEANED + COUNT ))
fi
fi
done < <(find "\$DATA_BASE/firms" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null)
# Check processing folder for stale files (flag but do not delete)
STALE_PROCESSING=\$(find "\$DATA_BASE/processing" -type f \
-mmin +\$(( PROCESSING_MAX_AGE * 60 )) 2>/dev/null | wc -l)
if [[ \$STALE_PROCESSING -gt 0 ]]; then
log "ALERT: \$STALE_PROCESSING stale file(s) in processing/ - possible crashed worker"
find "\$DATA_BASE/processing" -type f \
-mmin +\$(( PROCESSING_MAX_AGE * 60 )) 2>/dev/null | \
while read -r F; do log " STALE: \$F"; done
# Write alert marker for diagnostics panel to detect
echo "\$(date '+%Y-%m-%d %H:%M:%S') STALE_PROCESSING \$STALE_PROCESSING" \
>> "\$DATA_BASE/system/alerts.log"
fi
# Check imports folder for stale files
STALE_IMPORTS=\$(find "\$DATA_BASE/imports" -type f \
-mmin +\$(( IMPORTS_MAX_AGE * 60 )) 2>/dev/null | wc -l)
if [[ \$STALE_IMPORTS -gt 0 ]]; then
log "ALERT: \$STALE_IMPORTS stale file(s) in imports/ - possible failed import job"
echo "\$(date '+%Y-%m-%d %H:%M:%S') STALE_IMPORTS \$STALE_IMPORTS" \
>> "\$DATA_BASE/system/alerts.log"
fi
log "Cleanup complete. Temp files removed: \$TEMP_CLEANED | Processing stale: \$STALE_PROCESSING | Import stale: \$STALE_IMPORTS"
CLEANSCRIPT
chmod 750 "$CLEANUP_SCRIPT"
chown "$APP_USER:$APP_GROUP" "$CLEANUP_SCRIPT"
# Register cleanup cron job (runs every 30 minutes)
CRON_LINE="*/30 * * * * $APP_USER $CLEANUP_SCRIPT >> /var/log/jfmsrv01_cleanup.log 2>&1"
CRON_FILE="/etc/cron.d/jfmsrv01-cleanup"
echo "$CRON_LINE" > "$CRON_FILE"
chmod 644 "$CRON_FILE"
success "Cleanup cron job created (runs every 30 minutes): $CRON_FILE"
# Create log file with correct ownership
touch /var/log/jfmsrv01_cleanup.log
chown "$APP_USER:$APP_GROUP" /var/log/jfmsrv01_cleanup.log
# =============================================================================
# ClamAV integration script
# =============================================================================
info "Creating ClamAV scan helper"
SCAN_SCRIPT="$APP_BASE/scripts/scan_file.sh"
cat > "$SCAN_SCRIPT" <<'SCANSCRIPT'
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - File Virus Scan Helper
# Usage: scan_file.sh <filepath> <firm_id>
# Returns: 0 = clean, 1 = infected/quarantined, 2 = error
# Called by the application before accepting any uploaded file.
# =============================================================================
set -euo pipefail
FILE_PATH="${1:-}"
FIRM_ID="${2:-unknown}"
QUARANTINE_DIR="/data/jfmsrv01/quarantine"
LOG_FILE="/var/log/jfmsrv01_scan.log"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [SCAN] $*" | tee -a "$LOG_FILE"; }
[[ -z "$FILE_PATH" ]] && { echo "Usage: $0 <filepath> <firm_id>"; exit 2; }
[[ ! -f "$FILE_PATH" ]] && { log "ERROR: File not found: $FILE_PATH"; exit 2; }
log "Scanning: $FILE_PATH (firm: $FIRM_ID)"
SCAN_OUTPUT=$(clamscan --no-summary "$FILE_PATH" 2>&1)
SCAN_EXIT=$?
if [[ $SCAN_EXIT -eq 0 ]]; then
log "CLEAN: $FILE_PATH"
exit 0
elif [[ $SCAN_EXIT -eq 1 ]]; then
# Infected - move to quarantine
FILENAME=$(basename "$FILE_PATH")
QUARANTINE_PATH="$QUARANTINE_DIR/$(date +%Y%m%d_%H%M%S)_firm${FIRM_ID}_${FILENAME}"
mv "$FILE_PATH" "$QUARANTINE_PATH"
log "INFECTED: Moved to quarantine: $QUARANTINE_PATH"
log "ClamAV output: $SCAN_OUTPUT"
# Write alert for diagnostics
echo "$(date '+%Y-%m-%d %H:%M:%S') INFECTED firm=$FIRM_ID file=$FILENAME quarantine=$QUARANTINE_PATH" \
>> "/data/jfmsrv01/system/alerts.log"
exit 1
else
log "SCAN ERROR: $FILE_PATH | Exit: $SCAN_EXIT | Output: $SCAN_OUTPUT"
exit 2
fi
SCANSCRIPT
chmod 750 "$SCAN_SCRIPT"
chown "$APP_USER:$APP_GROUP" "$SCAN_SCRIPT"
# ClamAV freshclam cron (daily definition update check)
cat > /etc/cron.d/jfmsrv01-clamav <<'EOF'
# Update ClamAV definitions daily at 02:30
30 2 * * * root /usr/bin/freshclam --quiet >> /var/log/clamav/freshclam.log 2>&1
EOF
chmod 644 /etc/cron.d/jfmsrv01-clamav
success "ClamAV scan helper and daily update cron created"
# =============================================================================
# Log files
# =============================================================================
info "Creating application log files"
touch /var/log/jfmsrv01_cleanup.log
touch /var/log/jfmsrv01_scan.log
chown "$APP_USER:$APP_GROUP" \
/var/log/jfmsrv01_cleanup.log \
/var/log/jfmsrv01_scan.log
chmod 640 \
/var/log/jfmsrv01_cleanup.log \
/var/log/jfmsrv01_scan.log
# Add to logrotate
cat >> /etc/logrotate.d/jfmsrv01 <<EOF
/var/log/jfmsrv01_cleanup.log
/var/log/jfmsrv01_scan.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 640 $APP_USER $APP_GROUP
}
EOF
success "Log files created and added to logrotate"
# =============================================================================
# Verify layout
# =============================================================================
info "Verifying directory layout"
echo ""
echo "Data directory layout:"
tree -L 3 "$DATA_BASE" 2>/dev/null || find "$DATA_BASE" -maxdepth 3 -type d | sort
echo ""
echo "Backup directory layout:"
tree -L 2 "$BACKUP_BASE" 2>/dev/null || find "$BACKUP_BASE" -maxdepth 2 -type d | sort
echo ""
# Permission check
echo "Permission verification:"
ls -la "$DATA_BASE/"
echo ""
success "Storage layout complete"
info "Next step: run 02_app_skeleton.sh"
echo ""
echo "Firm storage helper: $FIRM_SETUP_SCRIPT"
echo "Usage: sudo -u $APP_USER $FIRM_SETUP_SCRIPT firm_0001"/jfmsrv01-build/02_app_skeleton.sh
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Phase 3
# Application Skeleton Setup
#
# Creates the Laravel application with all five mandatory foundations:
# 1. Feature flags
# 2. Tenant settings
# 3. Storage abstraction
# 4. AI abstraction (with privacy tier enforcement)
# 5. Event-based modules
#
# Also installs:
# - Multi-tenant foundation (firms, platform structure)
# - Users / roles / permissions
# - Notification system foundation
# - Basic matter/client module
# - Queue worker configuration (Supervisor)
# - Environment file
#
# Run as root after 01_storage_layout.sh
# =============================================================================
set -euo pipefail
APP_USER="jfmsrv01"
APP_BASE="/opt/jfmsrv01/app"
DATA_BASE="/data/jfmsrv01"
PHP_VERSION="8.3"
CREDS_FILE="/root/jfmsrv01_credentials.txt"
[[ -f "$CREDS_FILE" ]] && source "$CREDS_FILE" || {
echo "Credentials file not found at $CREDS_FILE"
echo "Set DB_PASSWORD and REDIS_PASSWORD manually below."
DB_PASSWORD="${DB_PASSWORD:-CHANGE_ME}"
REDIS_PASSWORD="${REDIS_PASSWORD:-CHANGE_ME}"
}
APP_KEY=$(php -r "echo 'base64:'.base64_encode(random_bytes(32));")
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "Run as root."
info "Setting up Laravel application skeleton"
# =============================================================================
# Install Laravel
# =============================================================================
info "Creating Laravel project at $APP_BASE"
if [[ ! -f "$APP_BASE/artisan" ]]; then
sudo -u "$APP_USER" composer create-project laravel/laravel "$APP_BASE" \
--prefer-dist --no-interaction 2>&1 | tail -5
success "Laravel project created"
else
success "Laravel project already exists"
fi
cd "$APP_BASE"
# =============================================================================
# Install packages
# =============================================================================
info "Installing Composer packages"
sudo -u "$APP_USER" composer require \
spatie/laravel-permission \
spatie/laravel-activitylog \
spatie/laravel-settings \
predis/predis \
--no-interaction 2>&1 | tail -5
success "Core packages installed"
# =============================================================================
# Environment file
# =============================================================================
info "Creating .env file"
cat > "$APP_BASE/.env" <<EOF
APP_NAME="JFM Server 01"
APP_ENV=production
APP_KEY=$APP_KEY
APP_DEBUG=false
APP_URL=https://jfmsrv011
LOG_CHANNEL=daily
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=${DB_NAME:-jfmsrv01}
DB_USERNAME=${DB_USER:-jfmsrv01}
DB_PASSWORD=${DB_PASSWORD}
BROADCAST_DRIVER=log
CACHE_DRIVER=redis
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=${REDIS_PASSWORD}
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@jfmsrv011"
MAIL_FROM_NAME="\${APP_NAME}"
# Storage paths
APP_STORAGE_BASE=$DATA_BASE/firms
APP_QUARANTINE_PATH=$DATA_BASE/quarantine
APP_PROCESSING_PATH=$DATA_BASE/processing
APP_IMPORTS_PATH=$DATA_BASE/imports
APP_SHARED_PATH=$DATA_BASE/shared
# ClamAV
CLAMAV_ENABLED=true
CLAMAV_SCAN_SCRIPT=/opt/jfmsrv01/scripts/scan_file.sh
# Deployment mode: multi_tenant | single_tenant_hosted | single_tenant_onsite
DEPLOYMENT_MODE=single_tenant_onsite
EOF
chown "$APP_USER:$APP_USER" "$APP_BASE/.env"
chmod 640 "$APP_BASE/.env"
success ".env file created"
# =============================================================================
# Storage symlink
# =============================================================================
sudo -u "$APP_USER" php artisan storage:link --quiet || true
# Fix storage permissions
chown -R "$APP_USER:$APP_USER" "$APP_BASE/storage" "$APP_BASE/bootstrap/cache"
chmod -R 775 "$APP_BASE/storage" "$APP_BASE/bootstrap/cache"
# =============================================================================
# Database migrations - foundations
# =============================================================================
info "Creating foundation migrations"
# 1. Firms / Tenants
cat > "$APP_BASE/database/migrations/$(date +%Y_%m_%d)_000001_create_firms_table.php" <<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('firms', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('status')->default('active'); // active, suspended, trial
$table->string('deployment_mode')->default('hosted'); // hosted, onsite, dedicated
$table->string('plan')->nullable();
$table->string('domain')->nullable()->unique();
$table->string('subdomain')->nullable()->unique();
$table->string('logo_path')->nullable();
$table->string('primary_colour')->default('#1a56db');
$table->string('secondary_colour')->default('#f3f4f6');
$table->string('timezone')->default('Australia/Perth');
$table->string('country')->default('AU');
$table->string('state')->nullable();
$table->string('jurisdiction')->default('WA'); // default legal jurisdiction
$table->string('storage_path')->nullable(); // override per-firm storage base
$table->string('storage_driver')->default('local'); // local, smb, sftp, s3
$table->integer('storage_quota_gb')->default(50);
$table->bigInteger('storage_used_bytes')->default(0);
$table->json('settings')->nullable(); // firm-level config overrides
$table->json('ai_settings')->nullable(); // AI provider preferences
$table->string('ai_privacy_tier')->default('2'); // 1=onprem, 2=private, 3=shared
$table->boolean('mfa_required')->default(false);
$table->string('retention_days')->default('2555'); // ~7 years default
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('suspended_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void { Schema::dropIfExists('firms'); }
};
PHP
# 2. Users (extend default, add firm_id + role)
cat > "$APP_BASE/database/migrations/$(date +%Y_%m_%d)_000002_add_firm_id_to_users_table.php" <<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('firm_id')->nullable()->constrained('firms')->nullOnDelete();
$table->boolean('is_platform_admin')->default(false);
$table->string('status')->default('active');
$table->string('mfa_secret')->nullable();
$table->boolean('mfa_enabled')->default(false);
$table->timestamp('last_login_at')->nullable();
$table->string('last_login_ip')->nullable();
$table->index('firm_id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'firm_id','is_platform_admin','status',
'mfa_secret','mfa_enabled','last_login_at','last_login_ip'
]);
});
}
};
PHP
# 3. Feature flags
cat > "$APP_BASE/database/migrations/$(date +%Y_%m_%d)_000003_create_feature_flags_table.php" <<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('feature_flags', function (Blueprint $table) {
$table->id();
// Scope: platform (global), firm, role, user
$table->string('scope')->default('platform');
$table->foreignId('firm_id')->nullable()->constrained('firms')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
$table->string('role')->nullable();
$table->string('feature'); // e.g. 'transcription', 'templates', 'ai_review'
$table->boolean('enabled')->default(false);
$table->json('config')->nullable(); // feature-specific config
$table->timestamps();
$table->unique(['scope','firm_id','user_id','role','feature'], 'feature_flags_unique');
$table->index(['firm_id','feature']);
$table->index(['scope','feature']);
});
// Global feature definitions
Schema::create('feature_definitions', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('label');
$table->text('description')->nullable();
$table->string('module')->nullable(); // which module this belongs to
$table->boolean('default_enabled')->default(false);
$table->json('plans')->nullable(); // which plans include this feature
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feature_flags');
Schema::dropIfExists('feature_definitions');
}
};
PHP
# 4. Matters / Clients
cat > "$APP_BASE/database/migrations/$(date +%Y_%m_%d)_000004_create_matters_table.php" <<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('matters', function (Blueprint $table) {
$table->id();
$table->foreignId('firm_id')->constrained('firms')->cascadeOnDelete();
$table->string('matter_number')->nullable();
$table->string('client_name');
$table->string('client_reference')->nullable();
$table->string('matter_type')->nullable(); // will, conveyancing, litigation, etc.
$table->text('description')->nullable();
$table->foreignId('assigned_lawyer_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('status')->default('open'); // open, closed, archived
$table->date('opened_date')->nullable();
$table->date('closed_date')->nullable();
$table->string('jurisdiction')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['firm_id','status']);
$table->index(['firm_id','matter_number']);
$table->unique(['firm_id','matter_number']);
});
}
public function down(): void { Schema::dropIfExists('matters'); }
};
PHP
# 5. Notifications
cat > "$APP_BASE/database/migrations/$(date +%Y_%m_%d)_000005_create_notifications_table.php" <<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('platform_notifications', function (Blueprint $table) {
$table->id();
$table->foreignId('firm_id')->nullable()->constrained('firms')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
$table->string('type'); // transcription_complete, job_failed, backup_failed, etc.
$table->string('level')->default('info'); // info, warning, error, success
$table->string('title');
$table->text('message');
$table->json('data')->nullable(); // extra context (job_id, file_name, etc.)
$table->string('link')->nullable(); // URL to relevant resource
$table->boolean('read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['firm_id','user_id','read']);
$table->index(['firm_id','type']);
});
}
public function down(): void { Schema::dropIfExists('platform_notifications'); }
};
PHP
# 6. Audit log
cat > "$APP_BASE/database/migrations/$(date +%Y_%m_%d)_000006_create_audit_logs_table.php" <<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('firm_id')->nullable()->constrained('firms')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->boolean('is_platform_admin_action')->default(false);
$table->boolean('is_impersonation')->default(false);
$table->foreignId('impersonating_admin_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('event'); // e.g. 'recording.uploaded', 'document.approved'
$table->string('auditable_type')->nullable(); // model class
$table->unsignedBigInteger('auditable_id')->nullable();
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->json('metadata')->nullable(); // IP, user agent, etc.
$table->string('ip_address')->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->index(['firm_id','event']);
$table->index(['firm_id','user_id']);
$table->index(['auditable_type','auditable_id']);
});
}
public function down(): void { Schema::dropIfExists('audit_logs'); }
};
PHP
success "Foundation migrations created"
# =============================================================================
# Core service providers and foundations
# =============================================================================
info "Creating TenantScope global scope"
mkdir -p "$APP_BASE/app/Models/Scopes"
cat > "$APP_BASE/app/Models/Scopes/TenantScope.php" <<'PHP'
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
/**
* TenantScope - Automatically filters all queries by the current firm.
* Applied to all tenant-owned models to prevent cross-firm data leakage.
*/
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($firmId = app('current_firm_id')) {
$builder->where($model->getTable() . '.firm_id', $firmId);
}
}
}
PHP
info "Creating Firm model"
cat > "$APP_BASE/app/Models/Firm.php" <<'PHP'
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Firm extends Model
{
use SoftDeletes;
protected $fillable = [
'name','slug','status','deployment_mode','plan','domain','subdomain',
'logo_path','primary_colour','secondary_colour','timezone','country',
'state','jurisdiction','storage_path','storage_driver','storage_quota_gb',
'storage_used_bytes','settings','ai_settings','ai_privacy_tier',
'mfa_required','retention_days','trial_ends_at','suspended_at',
];
protected $casts = [
'settings' => 'array',
'ai_settings' => 'array',
'mfa_required' => 'boolean',
'trial_ends_at' => 'datetime',
'suspended_at' => 'datetime',
];
public function users() { return $this->hasMany(User::class); }
public function matters() { return $this->hasMany(Matter::class); }
public function featureFlags(){ return $this->hasMany(FeatureFlag::class); }
public function hasFeature(string $feature): bool
{
return \App\Services\FeatureFlagService::firmHasFeature($this->id, $feature);
}
public function storagePath(string $subfolder = ''): string
{
$base = $this->storage_path ?: config('app.storage_base') . '/' . $this->id;
return $subfolder ? rtrim($base, '/') . '/' . ltrim($subfolder, '/') : $base;
}
public function isStorageQuotaExceeded(): bool
{
return $this->storage_used_bytes >= ($this->storage_quota_gb * 1024 * 1024 * 1024);
}
public function storageUsedPercent(): float
{
$quota = $this->storage_quota_gb * 1024 * 1024 * 1024;
return $quota > 0 ? round(($this->storage_used_bytes / $quota) * 100, 1) : 0;
}
}
PHP
info "Creating storage abstraction service"
mkdir -p "$APP_BASE/app/Services"
cat > "$APP_BASE/app/Services/StorageService.php" <<'PHP'
<?php
namespace App\Services;
use App\Models\Firm;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* StorageService - All firm file operations must go through this service.
* Enforces tenant isolation and path traversal protection.
* Never hard-code file paths in business logic - use this service.
*/
class StorageService
{
private Firm $firm;
private string $basePath;
public function __construct(Firm $firm)
{
$this->firm = $firm;
$this->basePath = $this->resolveFirmBase($firm);
}
private function resolveFirmBase(Firm $firm): string
{
$base = $firm->storage_path
?: rtrim(config('app.storage_base', '/data/jfmsrv01/firms'), '/') . '/' . $firm->id;
return rtrim($base, '/');
}
/**
* Resolve a path within this firm's storage.
* Throws if the resolved path would escape the firm's base directory.
*/
public function path(string $subfolder, string $filename = ''): string
{
$resolved = $this->basePath . '/' . ltrim($subfolder, '/');
if ($filename) {
$resolved .= '/' . ltrim($filename, '/');
}
// Normalise and validate - prevent path traversal
$real = realpath(dirname($resolved));
if ($real === false) {
// Directory doesn't exist yet - check the path string directly
$normalised = $this->normalisePath($resolved);
} else {
$normalised = $real . '/' . basename($resolved);
}
if (!str_starts_with($normalised, $this->basePath)) {
Log::error('Path traversal attempt blocked', [
'firm_id' => $this->firm->id,
'subfolder' => $subfolder,
'filename' => $filename,
'resolved' => $resolved,
]);
// Audit log
\App\Services\AuditService::log('storage.path_traversal_blocked', null, [
'subfolder' => $subfolder,
'filename' => $filename,
]);
throw new RuntimeException('Invalid storage path: access denied.');
}
return $normalised;
}
private function normalisePath(string $path): string
{
$parts = explode('/', $path);
$result = [];
foreach ($parts as $part) {
if ($part === '..') {
array_pop($result);
} elseif ($part !== '.') {
$result[] = $part;
}
}
return implode('/', $result);
}
public function recordingsPath(string $filename = ''): string
{
return $this->path('recordings', $filename);
}
public function transcriptsPath(string $filename = ''): string
{
return $this->path('transcripts', $filename);
}
public function documentsPath(string $filename = ''): string
{
return $this->path('documents', $filename);
}
public function templatesPath(string $filename = ''): string
{
return $this->path('templates', $filename);
}
public function tempPath(string $filename = ''): string
{
return $this->path('temp', $filename);
}
public function ensureDirectoryExists(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0750, true);
chown($path, 'jfmsrv01');
}
}
public function storageUsedBytes(): int
{
if (!is_dir($this->basePath)) return 0;
$output = shell_exec("du -sb " . escapeshellarg($this->basePath) . " 2>/dev/null");
return (int) explode("\t", trim($output ?? ''))[0];
}
public function updateStorageUsage(): void
{
$bytes = $this->storageUsedBytes();
$this->firm->update(['storage_used_bytes' => $bytes]);
}
}
PHP
info "Creating AI abstraction service"
cat > "$APP_BASE/app/Services/AiService.php" <<'PHP'
<?php
namespace App\Services;
use App\Models\Firm;
use RuntimeException;
/**
* AiService - All AI provider calls must go through this router.
* Enforces firm privacy tier restrictions.
* Never call AI providers directly from business logic.
*
* Privacy tiers:
* 1 = On-premise / self-hosted only (no data leaves firm infrastructure)
* 2 = Private API with no-training guarantee (data processing agreement required)
* 3 = Shared API (standard terms - use only if firm has approved)
*/
class AiService
{
// Task types
const TASK_TRANSCRIPTION = 'transcription';
const TASK_TRANSCRIPT_CLEANUP = 'transcript_cleanup';
const TASK_CLASSIFICATION = 'document_classification';
const TASK_TEMPLATE_SELECTION = 'template_selection';
const TASK_LEGAL_REVIEW = 'legal_review';
const TASK_EMAIL_DRAFT = 'email_draft';
const TASK_SECOND_CHECK = 'second_ai_check';
const TASK_SUMMARISATION = 'summarisation';
// Privacy tiers
const TIER_ONPREMISE = '1';
const TIER_PRIVATE = '2';
const TIER_SHARED = '3';
private Firm $firm;
private string $permittedTier;
public function __construct(Firm $firm)
{
$this->firm = $firm;
$this->permittedTier = $firm->ai_privacy_tier ?? self::TIER_PRIVATE;
}
/**
* Route a task to the appropriate AI provider, respecting the firm's tier.
*/
public function route(string $task, array $payload, ?string $preferredProvider = null): array
{
$provider = $this->selectProvider($task, $preferredProvider);
$this->enforcePrivacyTier($provider);
$this->logUsage($task, $provider);
return $this->dispatch($provider, $task, $payload);
}
private function selectProvider(string $task, ?string $preferred): array
{
// Future: load from global AI provider definitions table
// For MVP: use configured defaults
$defaults = config('ai.providers', []);
$taskDefaults = config("ai.task_defaults.$task", []);
if ($preferred && isset($defaults[$preferred])) {
return $defaults[$preferred];
}
if (!empty($taskDefaults) && isset($defaults[$taskDefaults['provider']])) {
return $defaults[$taskDefaults['provider']];
}
throw new RuntimeException("No AI provider configured for task: $task");
}
private function enforcePrivacyTier(array $provider): void
{
$providerTier = $provider['privacy_tier'] ?? self::TIER_SHARED;
if ($providerTier > $this->permittedTier) {
AuditService::log('ai.provider_tier_blocked', null, [
'firm_id' => $this->firm->id,
'provider' => $provider['name'] ?? 'unknown',
'provider_tier' => $providerTier,
'permitted_tier' => $this->permittedTier,
]);
throw new RuntimeException(
"AI provider tier ($providerTier) exceeds firm's permitted tier ({$this->permittedTier}). " .
"Update the firm's AI privacy tier setting to allow this provider."
);
}
}
private function logUsage(string $task, array $provider): void
{
// Future: write to ai_usage_logs table for cost metering
}
private function dispatch(array $provider, string $task, array $payload): array
{
// Future: implement provider adapters (OpenAI, Anthropic, Whisper, local, etc.)
// For MVP: return placeholder
return [
'provider' => $provider['name'] ?? 'not_configured',
'task' => $task,
'status' => 'pending_provider_configuration',
];
}
}
PHP
info "Creating feature flag service"
cat > "$APP_BASE/app/Services/FeatureFlagService.php" <<'PHP'
<?php
namespace App\Services;
use App\Models\FeatureFlag;
use Illuminate\Support\Facades\Cache;
/**
* FeatureFlagService - Checks whether features are enabled.
* Hierarchy: user > role > firm > global
* A disabled feature must be hidden from menus, blocked at routes,
* and must not run background jobs.
*/
class FeatureFlagService
{
public static function firmHasFeature(int $firmId, string $feature): bool
{
return Cache::remember("feature.$firmId.$feature", 300, function () use ($firmId, $feature) {
// Check firm-level flag
$flag = FeatureFlag::where('scope', 'firm')
->where('firm_id', $firmId)
->where('feature', $feature)
->first();
if ($flag) return $flag->enabled;
// Fall back to global/platform flag
$global = FeatureFlag::where('scope', 'platform')
->whereNull('firm_id')
->where('feature', $feature)
->first();
if ($global) return $global->enabled;
// Fall back to feature definition default
$def = \App\Models\FeatureDefinition::where('key', $feature)->first();
return $def ? $def->default_enabled : false;
});
}
public static function userHasFeature(int $userId, int $firmId, string $feature): bool
{
// Check user-level override first
$flag = FeatureFlag::where('scope', 'user')
->where('user_id', $userId)
->where('feature', $feature)
->first();
if ($flag) return $flag->enabled;
return static::firmHasFeature($firmId, $feature);
}
public static function clearCache(int $firmId, string $feature): void
{
Cache::forget("feature.$firmId.$feature");
}
}
PHP
info "Creating audit service"
cat > "$APP_BASE/app/Services/AuditService.php" <<'PHP'
<?php
namespace App\Services;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
use App\Models\AuditLog;
/**
* AuditService - All important actions must be logged here.
* Tenant-scoped. Never log secrets, passwords, or API keys.
*/
class AuditService
{
public static function log(
string $event,
?int $auditableId = null,
array $metadata = [],
string $auditableType = '',
array $oldValues = [],
array $newValues = []
): void {
try {
$user = Auth::user();
AuditLog::create([
'firm_id' => $user?->firm_id ?? app('current_firm_id'),
'user_id' => $user?->id,
'is_platform_admin_action' => $user?->is_platform_admin ?? false,
'is_impersonation' => session('is_impersonating', false),
'impersonating_admin_id' => session('impersonating_admin_id'),
'event' => $event,
'auditable_type' => $auditableType,
'auditable_id' => $auditableId,
'old_values' => $oldValues ?: null,
'new_values' => $newValues ?: null,
'metadata' => $metadata ?: null,
'ip_address' => Request::ip(),
'user_agent' => Request::userAgent(),
]);
} catch (\Throwable $e) {
// Audit log must never crash the application
\Illuminate\Support\Facades\Log::error('AuditService failed: ' . $e->getMessage());
}
}
}
PHP
info "Creating event definitions"
mkdir -p "$APP_BASE/app/Events"
for EVENT in \
"AudioUploaded" "AudioImported" \
"TranscriptionStarted" "TranscriptionCompleted" \
"TranscriptEdited" "TranscriptApproved" \
"DocumentTypeDetected" "TemplateSelected" \
"DocumentGenerated" "DocumentApproved" \
"AiReviewRequested" "AiReviewCompleted" \
"EmailDraftCreated" "EmailSent" \
"BackupCompleted" "BackupFailed" \
"JobFailed" "NotificationTriggered" \
"RetentionExpiryWarning" \
"SupportImpersonationStarted" "SupportImpersonationEnded"; do
cat > "$APP_BASE/app/Events/${EVENT}.php" <<PHPEOF
<?php
namespace App\\Events;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class ${EVENT}
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly int \$firmId,
public readonly array \$payload = []
) {}
}
PHPEOF
done
success "Event classes created (21 events)"
# =============================================================================
# Supervisor queue worker config
# =============================================================================
info "Creating Supervisor queue worker configuration"
cat > /etc/supervisor/conf.d/jfmsrv01-worker.conf <<EOF
[program:jfmsrv01-worker]
process_name=%(program_name)s_%(process_num)02d
command=php $APP_BASE/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --queue=high,default,low
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=$APP_USER
numprocs=4
redirect_stderr=true
stdout_logfile=$APP_BASE/storage/logs/worker.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stopwaitsecs=3600
[program:jfmsrv01-scheduler]
command=php $APP_BASE/artisan schedule:work
autostart=true
autorestart=true
user=$APP_USER
numprocs=1
redirect_stderr=true
stdout_logfile=$APP_BASE/storage/logs/scheduler.log
EOF
supervisorctl reread && supervisorctl update || warn "Supervisor reload failed - check manually"
success "Supervisor queue workers configured (4 workers)"
# =============================================================================
# Run migrations
# =============================================================================
info "Running database migrations"
sudo -u "$APP_USER" php "$APP_BASE/artisan" migrate --force 2>&1 | tail -10
success "Migrations complete"
# =============================================================================
# Seed feature definitions
# =============================================================================
info "Seeding feature definitions"
cat > "$APP_BASE/database/seeders/FeatureDefinitionSeeder.php" <<'PHP'
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\FeatureDefinition;
class FeatureDefinitionSeeder extends Seeder
{
public function run(): void
{
$features = [
// Level 1
['key'=>'transcription', 'label'=>'Transcription', 'module'=>'transcription', 'default_enabled'=>true, 'plans'=>['basic','standard','professional','enterprise']],
['key'=>'transcript_viewer', 'label'=>'Transcript Viewer/Editor', 'module'=>'transcription', 'default_enabled'=>true, 'plans'=>['basic','standard','professional','enterprise']],
// Level 2
['key'=>'templates', 'label'=>'Word Templates', 'module'=>'templates', 'default_enabled'=>false, 'plans'=>['standard','professional','enterprise']],
['key'=>'document_generation', 'label'=>'Document Generation', 'module'=>'templates', 'default_enabled'=>false, 'plans'=>['standard','professional','enterprise']],
['key'=>'document_watermark', 'label'=>'Draft Watermarking', 'module'=>'templates', 'default_enabled'=>false, 'plans'=>['standard','professional','enterprise']],
// Level 3
['key'=>'file_connectors', 'label'=>'SMB/SFTP/FTP Connectors', 'module'=>'connectors', 'default_enabled'=>false, 'plans'=>['professional','enterprise']],
['key'=>'folder_watcher', 'label'=>'Watched Folder Import', 'module'=>'connectors', 'default_enabled'=>false, 'plans'=>['professional','enterprise']],
['key'=>'local_agent', 'label'=>'Local Connector Agent', 'module'=>'connectors', 'default_enabled'=>false, 'plans'=>['professional','enterprise']],
// Level 4
['key'=>'ai_cleanup', 'label'=>'AI Transcript Cleanup', 'module'=>'ai', 'default_enabled'=>false, 'plans'=>['professional','enterprise']],
['key'=>'ai_classification', 'label'=>'AI Document Classification', 'module'=>'ai', 'default_enabled'=>false, 'plans'=>['professional','enterprise']],
['key'=>'ai_legal_review', 'label'=>'AI Legal Review', 'module'=>'ai', 'default_enabled'=>false, 'plans'=>['enterprise']],
['key'=>'ai_second_checker', 'label'=>'Second AI Checker', 'module'=>'ai', 'default_enabled'=>false, 'plans'=>['enterprise']],
// Level 5
['key'=>'email_integration', 'label'=>'Email Integration', 'module'=>'email', 'default_enabled'=>false, 'plans'=>['professional','enterprise']],
['key'=>'email_ai_reply', 'label'=>'AI Email Drafting', 'module'=>'email', 'default_enabled'=>false, 'plans'=>['enterprise']],
// Level 6
['key'=>'live_dictation', 'label'=>'Live Browser Dictation', 'module'=>'dictation', 'default_enabled'=>false, 'plans'=>['enterprise']],
// Level 7
['key'=>'voip_dictation', 'label'=>'VoIP/SIP Dictation', 'module'=>'voip', 'default_enabled'=>false, 'plans'=>['enterprise']],
// Platform
['key'=>'matters', 'label'=>'Matter/Client Management', 'module'=>'matters', 'default_enabled'=>true, 'plans'=>['basic','standard','professional','enterprise']],
['key'=>'audit_log', 'label'=>'Audit Log', 'module'=>'audit', 'default_enabled'=>true, 'plans'=>['basic','standard','professional','enterprise']],
['key'=>'notifications', 'label'=>'Notifications', 'module'=>'notifications', 'default_enabled'=>true, 'plans'=>['basic','standard','professional','enterprise']],
['key'=>'backups', 'label'=>'Backups', 'module'=>'backups', 'default_enabled'=>true, 'plans'=>['basic','standard','professional','enterprise']],
];
foreach ($features as $feature) {
FeatureDefinition::updateOrCreate(
['key' => $feature['key']],
array_merge($feature, ['plans' => json_encode($feature['plans'])])
);
}
}
}
PHP
sudo -u "$APP_USER" php "$APP_BASE/artisan" db:seed --class=FeatureDefinitionSeeder --force 2>&1 | tail -5
success "Feature definitions seeded"
# =============================================================================
# Fix final permissions
# =============================================================================
chown -R "$APP_USER:$APP_USER" "$APP_BASE"
chmod -R 755 "$APP_BASE"
chmod 640 "$APP_BASE/.env"
find "$APP_BASE/storage" -type d -exec chmod 775 {} \;
find "$APP_BASE/bootstrap/cache" -type d -exec chmod 775 {} \;
# =============================================================================
# Summary
# =============================================================================
success "=== APPLICATION SKELETON COMPLETE ==="
echo ""
echo "Five mandatory foundations installed:"
echo " 1. Feature flags - FeatureFlagService + feature_flags table"
echo " 2. Tenant settings - Firm model + firms table"
echo " 3. Storage abstraction - StorageService with path traversal protection"
echo " 4. AI abstraction - AiService with privacy tier enforcement"
echo " 5. Event system - 21 event classes created"
echo ""
echo "Also installed:"
echo " - Multi-tenant foundation (Platform -> Firm -> User)"
echo " - Users + roles (spatie/laravel-permission)"
echo " - Audit log (AuditService + audit_logs table)"
echo " - Notifications (platform_notifications table)"
echo " - Matter/client management (matters table)"
echo " - Queue workers (4 Supervisor workers)"
echo " - 20 feature definitions seeded"
echo ""
echo "Next step: run 03_admin_safety_tools.sh"/jfmsrv01-build/03_admin_safety_tools.sh
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Phase 4
# Admin Safety Tools Setup
#
# Creates all developer/admin safety tooling:
# - Backup scripts (source, database, file, tenant export)
# - Changelog file
# - Handover file
# - Route list report
# - Schema summary report
# - Error log tail report
# - Smoke test script
# - Admin diagnostics data endpoint
# - Patch workflow script (backup -> apply -> smoke test -> log)
#
# Run as root after 02_app_skeleton.sh
# =============================================================================
set -euo pipefail
APP_USER="jfmsrv01"
APP_BASE="/opt/jfmsrv01/app"
SCRIPTS_BASE="/opt/jfmsrv01/scripts"
DATA_BASE="/data/jfmsrv01"
BACKUP_BASE="/backups/jfmsrv01"
PHP_VERSION="8.3"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "Run as root."
mkdir -p "$SCRIPTS_BASE"
# =============================================================================
# Changelog file
# =============================================================================
info "Creating changelog file"
CHANGELOG="$SCRIPTS_BASE/CHANGELOG.md"
if [[ ! -f "$CHANGELOG" ]]; then
cat > "$CHANGELOG" <<EOF
# JFM Server 01 - Changelog
## $(date +%Y-%m-%d) - Initial Setup
### Added
- Server provisioned: Ubuntu 24.04 LTS
- Phase 0: System hardening, firewall, SSH, fail2ban
- Phase 1: Nginx, PHP $PHP_VERSION, PostgreSQL, Redis, ClamAV, ffmpeg, LibreOffice
- Phase 2: Storage layout, firm folder structure, ClamAV scan helper, cleanup cron
- Phase 3: Laravel application skeleton
- Five mandatory foundations: feature flags, tenant settings, storage abstraction,
AI abstraction (with privacy tier enforcement), event-based modules
- Multi-tenant architecture (Platform -> Firm -> User)
- Audit log, notifications, matter/client management
- 21 event classes, 20 feature definitions
- Phase 4: Admin safety tools (this entry)
### Architecture decisions
- Database: PostgreSQL (row-level security, better JSON, stronger integrity)
- Queue: Redis with AOF persistence and noeviction policy
- Storage: Shared disk with per-firm logical folders, path traversal protection
- AI: Privacy tier model (1=onprem, 2=private, 3=shared), firm-level enforcement
- Tenancy: Shared database + firm_id on all tenant tables, separate firm folders
EOF
fi
chown "$APP_USER:$APP_USER" "$CHANGELOG"
success "Changelog created: $CHANGELOG"
# =============================================================================
# Handover file
# =============================================================================
info "Creating handover file"
HANDOVER="$SCRIPTS_BASE/HANDOVER.md"
cat > "$HANDOVER" <<EOF
# JFM Server 01 - Handover Document
**Last updated:** $(date '+%Y-%m-%d %H:%M:%S')
**Server:** $(hostname)
**Platform version:** MVP Phase 4
---
## Current state
The server has completed Phases 0-4:
- Server provisioned and hardened
- All services installed (Nginx, PostgreSQL, Redis, ClamAV, PHP, Python, ffmpeg)
- Laravel application skeleton created with all five mandatory foundations
- Database migrations run, feature definitions seeded
## What has been built
### Five mandatory foundations
1. **Feature flags** - \`FeatureFlagService\`, \`feature_flags\` table, 20 feature definitions
2. **Tenant settings** - \`Firm\` model, \`firms\` table, per-firm config
3. **Storage abstraction** - \`StorageService\` with path traversal protection
4. **AI abstraction** - \`AiService\` with privacy tier enforcement (tiers 1/2/3)
5. **Event system** - 21 event classes under \`app/Events/\`
### Other foundations
- Multi-tenant architecture: Platform -> Firm -> User
- Audit log: \`AuditService\`, \`audit_logs\` table
- Notifications: \`platform_notifications\` table
- Matter/client management: \`matters\` table
- Queue workers: 4 Supervisor workers + scheduler
## What needs to be built next (Phase 5)
- Manual audio upload form (firm-scoped, with ClamAV scan)
- Transcription job model and migration
- Queue job: \`ProcessTranscriptionJob\`
- Transcript model + viewer/editor UI
- Basic admin theme (shared, not page-specific CSS)
## Key file locations
| Path | Purpose |
|------|---------|
| \`/opt/jfmsrv01/app\` | Laravel application |
| \`/opt/jfmsrv01/app/.env\` | Environment configuration |
| \`/data/jfmsrv01/firms/\` | Per-firm file storage |
| \`/data/jfmsrv01/quarantine/\` | Quarantined files (failed virus scan) |
| \`/backups/jfmsrv01/\` | Backup destination |
| \`/opt/jfmsrv01/scripts/\` | Admin scripts |
| \`/etc/supervisor/conf.d/jfmsrv01-worker.conf\` | Queue worker config |
| \`/etc/nginx/sites-available/jfmsrv01\` | Nginx config |
| \`/root/jfmsrv01_credentials.txt\` | Generated passwords (root only) |
## Key commands
\`\`\`bash
# Check queue workers
supervisorctl status
# Run migrations
sudo -u jfmsrv01 php /opt/jfmsrv01/app/artisan migrate
# Run smoke test
sudo bash /opt/jfmsrv01/scripts/smoke_test.sh
# Create backup
sudo bash /opt/jfmsrv01/scripts/backup.sh source
# View error log
tail -50 /opt/jfmsrv01/app/storage/logs/laravel.log
# Create firm storage
sudo -u jfmsrv01 /opt/jfmsrv01/scripts/create_firm_storage.sh firm_0001
\`\`\`
## Architecture notes
- All file access must use \`StorageService\` - never hard-code paths
- All AI calls must use \`AiService\` - it enforces the firm's privacy tier
- All feature checks must use \`FeatureFlagService\` - check before showing UI or running jobs
- All important actions must call \`AuditService::log()\`
- Every tenant-owned model must have \`firm_id\` and use \`TenantScope\`
- Logs must never contain secrets, passwords, or API keys at any level
## Deployment mode
Current: \`single_tenant_onsite\` (set \`DEPLOYMENT_MODE\` in \`.env\`)
Options: \`multi_tenant\` | \`single_tenant_hosted\` | \`single_tenant_onsite\`
EOF
chown "$APP_USER:$APP_USER" "$HANDOVER"
success "Handover file created: $HANDOVER"
# =============================================================================
# Backup script
# =============================================================================
info "Creating backup script"
cat > "$SCRIPTS_BASE/backup.sh" <<'BACKUPSCRIPT'
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Backup Script
# Usage: backup.sh [source|database|files|tenant <firm_id>|full]
# =============================================================================
set -euo pipefail
APP_BASE="/opt/jfmsrv01/app"
DATA_BASE="/data/jfmsrv01"
BACKUP_BASE="/backups/jfmsrv01"
SCRIPTS_BASE="/opt/jfmsrv01/scripts"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="$BACKUP_BASE/reports/backup_${TIMESTAMP}.log"
RETENTION_DAYS=30
mkdir -p "$BACKUP_BASE/reports"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$LOG_FILE"; }
success() { log "[OK] $*"; }
error() { log "[ERROR] $*"; exit 1; }
BACKUP_TYPE="${1:-full}"
log "Starting backup: type=$BACKUP_TYPE timestamp=$TIMESTAMP"
do_source_backup() {
log "Source backup starting"
DEST="$BACKUP_BASE/source/source_${TIMESTAMP}.tar.gz"
tar -czf "$DEST" \
--exclude="$APP_BASE/node_modules" \
--exclude="$APP_BASE/vendor" \
--exclude="$APP_BASE/storage/framework/cache" \
--exclude="$APP_BASE/storage/framework/sessions" \
"$APP_BASE" \
"$SCRIPTS_BASE" \
/etc/nginx/sites-available/jfmsrv01 \
/etc/supervisor/conf.d/jfmsrv01-worker.conf \
2>/dev/null
SIZE=$(du -sh "$DEST" | cut -f1)
success "Source backup: $DEST ($SIZE)"
# Keep last N source backups
ls -t "$BACKUP_BASE/source/source_"*.tar.gz 2>/dev/null | tail -n +11 | xargs -r rm
}
do_database_backup() {
log "Database backup starting"
DEST="$BACKUP_BASE/database/db_${TIMESTAMP}.sql.gz"
CREDS="/root/jfmsrv01_credentials.txt"
[[ -f "$CREDS" ]] && source "$CREDS"
PGPASSWORD="${DB_PASSWORD:-}" pg_dump \
-U "${DB_USER:-jfmsrv01}" \
-h 127.0.0.1 \
"${DB_NAME:-jfmsrv01}" | gzip > "$DEST"
SIZE=$(du -sh "$DEST" | cut -f1)
success "Database backup: $DEST ($SIZE)"
# Schema summary
SCHEMA_DEST="$BACKUP_BASE/database/schema_${TIMESTAMP}.txt"
PGPASSWORD="${DB_PASSWORD:-}" psql \
-U "${DB_USER:-jfmsrv01}" \
-h 127.0.0.1 \
"${DB_NAME:-jfmsrv01}" \
-c "\dt" > "$SCHEMA_DEST" 2>&1
success "Schema summary: $SCHEMA_DEST"
# Keep last 30 database backups
ls -t "$BACKUP_BASE/database/db_"*.sql.gz 2>/dev/null | tail -n +31 | xargs -r rm
}
do_files_backup() {
log "File backup starting (incremental)"
DEST="$BACKUP_BASE/files/files_${TIMESTAMP}.tar.gz"
tar -czf "$DEST" \
--exclude="$DATA_BASE/processing" \
--exclude="$DATA_BASE/imports" \
"$DATA_BASE/firms" \
"$DATA_BASE/shared" \
2>/dev/null || true
SIZE=$(du -sh "$DEST" | cut -f1)
success "File backup: $DEST ($SIZE)"
}
do_tenant_backup() {
FIRM_ID="${2:-}"
[[ -z "$FIRM_ID" ]] && { log "Usage: backup.sh tenant <firm_id>"; exit 1; }
log "Tenant backup: $FIRM_ID"
FIRM_DIR="$DATA_BASE/firms/$FIRM_ID"
[[ ! -d "$FIRM_DIR" ]] && error "Firm directory not found: $FIRM_DIR"
mkdir -p "$BACKUP_BASE/tenants/$FIRM_ID"
DEST="$BACKUP_BASE/tenants/$FIRM_ID/backup_${TIMESTAMP}.tar.gz"
tar -czf "$DEST" "$FIRM_DIR" 2>/dev/null
SIZE=$(du -sh "$DEST" | cut -f1)
success "Tenant backup: $DEST ($SIZE)"
}
do_handover_backup() {
log "Backing up handover and changelog"
cp "$SCRIPTS_BASE/CHANGELOG.md" "$BACKUP_BASE/handovers/CHANGELOG_${TIMESTAMP}.md" 2>/dev/null || true
cp "$SCRIPTS_BASE/HANDOVER.md" "$BACKUP_BASE/handovers/HANDOVER_${TIMESTAMP}.md" 2>/dev/null || true
success "Handover files backed up"
}
case "$BACKUP_TYPE" in
source) do_source_backup ;;
database) do_database_backup ;;
files) do_files_backup ;;
tenant) do_tenant_backup "$@" ;;
full)
do_source_backup
do_database_backup
do_files_backup
do_handover_backup
;;
*) error "Unknown backup type: $BACKUP_TYPE. Use: source|database|files|tenant <id>|full" ;;
esac
log "Backup complete: $BACKUP_TYPE"
BACKUPSCRIPT
chmod 750 "$SCRIPTS_BASE/backup.sh"
# =============================================================================
# Route list report
# =============================================================================
info "Creating route list report script"
cat > "$SCRIPTS_BASE/route_list.sh" <<EOF
#!/usr/bin/env bash
# Generates current route list for handover/review
cd $APP_BASE
php artisan route:list --columns=method,uri,name,action 2>/dev/null | tee /tmp/route_list_\$(date +%Y%m%d).txt
echo "Saved to: /tmp/route_list_\$(date +%Y%m%d).txt"
EOF
chmod 750 "$SCRIPTS_BASE/route_list.sh"
# =============================================================================
# Error log tail report
# =============================================================================
info "Creating log tail script"
cat > "$SCRIPTS_BASE/log_tail.sh" <<EOF
#!/usr/bin/env bash
# Shows last N lines of application error log
LINES="\${1:-50}"
LOG="$APP_BASE/storage/logs/laravel.log"
[[ -f "\$LOG" ]] && tail -n "\$LINES" "\$LOG" || echo "No log file found at \$LOG"
EOF
chmod 750 "$SCRIPTS_BASE/log_tail.sh"
# =============================================================================
# Smoke test script
# =============================================================================
info "Creating smoke test script"
cat > "$SCRIPTS_BASE/smoke_test.sh" <<'SMOKETEST'
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Smoke Test
# Run before and after every patch.
# Exit code 0 = all pass. Non-zero = failures detected.
# =============================================================================
set -euo pipefail
APP_BASE="/opt/jfmsrv01/app"
DATA_BASE="/data/jfmsrv01"
PASS=0; FAIL=0; WARN=0
REPORT_FILE="/tmp/smoke_test_$(date +%Y%m%d_%H%M%S).txt"
p() { echo "[PASS] $*"; echo "[PASS] $*" >> "$REPORT_FILE"; ((PASS++)); }
f() { echo "[FAIL] $*"; echo "[FAIL] $*" >> "$REPORT_FILE"; ((FAIL++)); }
w() { echo "[WARN] $*"; echo "[WARN] $*" >> "$REPORT_FILE"; ((WARN++)); }
echo "=== Smoke Test $(date) ===" | tee "$REPORT_FILE"
# PHP
php -v &>/dev/null && p "PHP available" || f "PHP not available"
# Artisan
[[ -f "$APP_BASE/artisan" ]] && p "artisan exists" || f "artisan not found"
# .env
[[ -f "$APP_BASE/.env" ]] && p ".env exists" || f ".env not found"
# Database connection
php "$APP_BASE/artisan" db:show &>/dev/null && p "Database connection OK" || f "Database connection FAILED"
# Migrations
PENDING=$(php "$APP_BASE/artisan" migrate:status 2>/dev/null | grep -c "Pending" || true)
[[ "$PENDING" -eq 0 ]] && p "No pending migrations" || w "$PENDING pending migrations"
# Storage paths
[[ -d "$DATA_BASE/firms" ]] && p "firms/ directory exists" || f "firms/ directory MISSING"
[[ -d "$DATA_BASE/quarantine" ]] && p "quarantine/ directory exists" || f "quarantine/ directory MISSING"
[[ -d "$DATA_BASE/processing" ]] && p "processing/ directory exists" || f "processing/ directory MISSING"
# Permissions
OWNER=$(stat -c '%U' "$DATA_BASE" 2>/dev/null || echo "unknown")
[[ "$OWNER" == "jfmsrv01" ]] && p "Data dir owned by jfmsrv01" || f "Data dir owner wrong: $OWNER"
# Redis
redis-cli -a "$(grep REDIS_PASSWORD /opt/jfmsrv01/app/.env | cut -d= -f2)" ping 2>/dev/null | grep -q PONG && \
p "Redis responding" || f "Redis not responding"
# Nginx
nginx -t 2>/dev/null && p "Nginx config valid" || f "Nginx config INVALID"
systemctl is-active nginx &>/dev/null && p "Nginx running" || f "Nginx not running"
# Queue workers
WORKERS=$(supervisorctl status jfmsrv01-worker: 2>/dev/null | grep -c RUNNING || true)
[[ "$WORKERS" -gt 0 ]] && p "Queue workers running ($WORKERS)" || w "No queue workers running"
# ClamAV
systemctl is-active clamav-daemon &>/dev/null && p "ClamAV daemon running" || w "ClamAV daemon not running"
CLAM_AGE=$(find /var/lib/clamav -name "*.cvd" -o -name "*.cld" 2>/dev/null | \
xargs stat -c '%Y' 2>/dev/null | sort -n | tail -1 || echo 0)
NOW=$(date +%s)
AGE_DAYS=$(( (NOW - ${CLAM_AGE:-0}) / 86400 ))
[[ "$AGE_DAYS" -lt 3 ]] && p "ClamAV definitions up to date (${AGE_DAYS}d old)" || w "ClamAV definitions may be stale (${AGE_DAYS}d old)"
# Stale processing files
STALE_PROCESSING=$(find "$DATA_BASE/processing" -type f -mmin +120 2>/dev/null | wc -l)
[[ "$STALE_PROCESSING" -eq 0 ]] && p "No stale processing files" || w "$STALE_PROCESSING stale file(s) in processing/"
# Log file errors (last 100 lines)
RECENT_ERRORS=$(tail -100 "$APP_BASE/storage/logs/laravel.log" 2>/dev/null | grep -c "\.ERROR:" || true)
[[ "$RECENT_ERRORS" -eq 0 ]] && p "No recent errors in application log" || w "$RECENT_ERRORS error(s) in recent log"
# Secret check - ensure no secrets leaked into log
if grep -q "password\|api_key\|secret" "$APP_BASE/storage/logs/laravel.log" 2>/dev/null; then
f "Possible secret in application log - review immediately"
else
p "No obvious secrets in application log"
fi
# Firewall
ufw status | grep -q "Status: active" && p "Firewall active" || w "Firewall not active"
# TLS certificate expiry
DOMAIN=$(grep server_name /etc/nginx/sites-available/jfmsrv01 2>/dev/null | \
grep -v "_" | awk '{print $2}' | tr -d ';' | head -1)
if [[ -n "$DOMAIN" && "$DOMAIN" != "_" ]]; then
EXPIRY=$(echo | timeout 5 openssl s_client -servername "$DOMAIN" \
-connect "$DOMAIN:443" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
if [[ -n "$EXPIRY" ]]; then
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || echo 0)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW) / 86400 ))
[[ "$DAYS_LEFT" -gt 14 ]] && p "TLS cert expires in ${DAYS_LEFT} days" || \
w "TLS cert expires in ${DAYS_LEFT} days - RENEW SOON"
fi
fi
echo ""
echo "=== Results: $PASS passed | $FAIL failed | $WARN warnings ==="
echo "Report saved: $REPORT_FILE"
cp "$REPORT_FILE" "/backups/jfmsrv01/reports/" 2>/dev/null || true
[[ "$FAIL" -eq 0 ]] && exit 0 || exit 1
SMOKETEST
chmod 750 "$SCRIPTS_BASE/smoke_test.sh"
success "Smoke test script created"
# =============================================================================
# Patch workflow script
# =============================================================================
info "Creating patch workflow script"
cat > "$SCRIPTS_BASE/patch.sh" <<'PATCHSCRIPT'
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - Patch Workflow
# Run this before and after every patch.
#
# Usage:
# patch.sh pre "Description of patch" # Before applying changes
# patch.sh post "Description of patch" # After applying changes
# =============================================================================
set -euo pipefail
APP_BASE="/opt/jfmsrv01/app"
SCRIPTS_BASE="/opt/jfmsrv01/scripts"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
STAGE="${1:-pre}"
DESCRIPTION="${2:-No description provided}"
case "$STAGE" in
pre)
info "=== PRE-PATCH: $DESCRIPTION ==="
info "Step 1/3: Source backup"
bash "$SCRIPTS_BASE/backup.sh" source
info "Step 2/3: Pre-patch smoke test"
bash "$SCRIPTS_BASE/smoke_test.sh" || warn "Pre-patch smoke test had failures - proceed with caution"
info "Step 3/3: Ready"
success "Pre-patch complete. Apply your changes now, then run: patch.sh post \"$DESCRIPTION\""
;;
post)
info "=== POST-PATCH: $DESCRIPTION ==="
info "Step 1/5: Clear caches"
sudo -u jfmsrv01 php "$APP_BASE/artisan" config:clear
sudo -u jfmsrv01 php "$APP_BASE/artisan" cache:clear
sudo -u jfmsrv01 php "$APP_BASE/artisan" view:clear
sudo -u jfmsrv01 php "$APP_BASE/artisan" route:clear
sudo -u jfmsrv01 php "$APP_BASE/artisan" config:cache
sudo -u jfmsrv01 php "$APP_BASE/artisan" route:cache
info "Step 2/5: Run migrations if any"
sudo -u jfmsrv01 php "$APP_BASE/artisan" migrate --force
info "Step 3/5: Reload services"
supervisorctl restart all || warn "Supervisor restart failed"
systemctl reload nginx || warn "Nginx reload failed"
info "Step 4/5: Post-patch smoke test"
bash "$SCRIPTS_BASE/smoke_test.sh" || {
warn "POST-PATCH SMOKE TEST FAILED"
warn "Review failures above. If critical, restore from backup."
warn "Backup location: /backups/jfmsrv01/source/"
}
info "Step 5/5: Update changelog"
DATE=$(date '+%Y-%m-%d')
echo "" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "## $DATE - $DESCRIPTION" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "### Changed" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "- $DESCRIPTION" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "### Smoke test" >> "$SCRIPTS_BASE/CHANGELOG.md"
echo "- Run post-patch: $(date '+%Y-%m-%d %H:%M:%S')" >> "$SCRIPTS_BASE/CHANGELOG.md"
success "Post-patch complete. Remember to update HANDOVER.md if architecture changed."
;;
*)
error "Usage: patch.sh [pre|post] \"Description\""
;;
esac
PATCHSCRIPT
chmod 750 "$SCRIPTS_BASE/patch.sh"
success "Patch workflow script created"
# =============================================================================
# Diagnostics data script (feeds admin diagnostics panel)
# =============================================================================
info "Creating diagnostics script"
cat > "$SCRIPTS_BASE/diagnostics.sh" <<'DIAGSCRIPT'
#!/usr/bin/env bash
# =============================================================================
# Outputs a JSON diagnostics report for the admin panel.
# Called by the Laravel DiagnosticsController.
# =============================================================================
APP_BASE="/opt/jfmsrv01/app"
DATA_BASE="/data/jfmsrv01"
REDIS_PASS=$(grep REDIS_PASSWORD "$APP_BASE/.env" 2>/dev/null | cut -d= -f2 || echo "")
# Disk usage
DATA_USED=$(df -B1 "$DATA_BASE" 2>/dev/null | tail -1 | awk '{print $3}')
DATA_AVAIL=$(df -B1 "$DATA_BASE" 2>/dev/null | tail -1 | awk '{print $4}')
OS_USED=$(df -B1 / 2>/dev/null | tail -1 | awk '{print $3}')
OS_AVAIL=$(df -B1 / 2>/dev/null | tail -1 | awk '{print $4}')
# Queue depth
QUEUE_DEPTH=$(redis-cli -a "$REDIS_PASS" llen queues:default 2>/dev/null || echo 0)
QUEUE_HIGH=$(redis-cli -a "$REDIS_PASS" llen queues:high 2>/dev/null || echo 0)
# Workers
WORKERS=$(supervisorctl status 2>/dev/null | grep -c RUNNING || echo 0)
# ClamAV definition age
CLAM_FILE=$(find /var/lib/clamav -name "*.cvd" -o -name "*.cld" 2>/dev/null | \
xargs stat -c '%Y' 2>/dev/null | sort -n | tail -1 || echo 0)
NOW=$(date +%s)
CLAM_AGE_HOURS=$(( (NOW - ${CLAM_FILE:-$NOW}) / 3600 ))
# Stale files
STALE_PROCESSING=$(find "$DATA_BASE/processing" -type f -mmin +120 2>/dev/null | wc -l)
STALE_IMPORTS=$(find "$DATA_BASE/imports" -type f -mmin +60 2>/dev/null | wc -l)
# Recent log errors
RECENT_ERRORS=$(tail -200 "$APP_BASE/storage/logs/laravel.log" 2>/dev/null | grep -c "\.ERROR:" || echo 0)
# Alerts
ALERTS=0
[[ -f "$DATA_BASE/system/alerts.log" ]] && \
ALERTS=$(grep -c "$(date +%Y-%m-%d)" "$DATA_BASE/system/alerts.log" 2>/dev/null || echo 0)
cat <<JSON
{
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"hostname": "$(hostname)",
"disk": {
"data_used_bytes": ${DATA_USED:-0},
"data_avail_bytes": ${DATA_AVAIL:-0},
"os_used_bytes": ${OS_USED:-0},
"os_avail_bytes": ${OS_AVAIL:-0}
},
"queue": {
"depth_default": ${QUEUE_DEPTH:-0},
"depth_high": ${QUEUE_HIGH:-0},
"workers_running": ${WORKERS:-0}
},
"clamav": {
"definition_age_hours": ${CLAM_AGE_HOURS:-999},
"daemon_active": $(systemctl is-active clamav-daemon &>/dev/null && echo true || echo false)
},
"stale_files": {
"processing": ${STALE_PROCESSING:-0},
"imports": ${STALE_IMPORTS:-0}
},
"log_errors_recent": ${RECENT_ERRORS:-0},
"alerts_today": ${ALERTS:-0},
"services": {
"nginx": "$(systemctl is-active nginx 2>/dev/null || echo unknown)",
"postgresql": "$(systemctl is-active postgresql 2>/dev/null || echo unknown)",
"redis": "$(systemctl is-active redis-server 2>/dev/null || echo unknown)",
"clamav": "$(systemctl is-active clamav-daemon 2>/dev/null || echo unknown)",
"supervisor": "$(systemctl is-active supervisor 2>/dev/null || echo unknown)"
}
}
JSON
DIAGSCRIPT
chmod 750 "$SCRIPTS_BASE/diagnostics.sh"
# =============================================================================
# Set ownership and run initial smoke test
# =============================================================================
chown -R "$APP_USER:$APP_USER" "$SCRIPTS_BASE"
chmod 750 "$SCRIPTS_BASE"
success "=== PHASE 4 COMPLETE: Admin safety tools installed ==="
echo ""
echo "Scripts created in $SCRIPTS_BASE:"
echo " backup.sh - Backup (source|database|files|tenant <id>|full)"
echo " smoke_test.sh - Full smoke test (run before/after every patch)"
echo " patch.sh - Patch workflow (pre + post)"
echo " diagnostics.sh - JSON diagnostics for admin panel"
echo " route_list.sh - Route list report"
echo " log_tail.sh - Error log tail"
echo " create_firm_storage.sh - Create storage dirs for a new firm"
echo " CHANGELOG.md - Change history"
echo " HANDOVER.md - Current state and handover notes"
echo ""
info "Running initial smoke test..."
bash "$SCRIPTS_BASE/smoke_test.sh" || warn "Some checks failed - review above"
echo ""
echo "Next step: run 04_first_firm.sh to create your first firm"/jfmsrv01-build/04_first_firm.sh
#!/usr/bin/env bash
# =============================================================================
# JFM Server 01 - First Firm Setup
# Creates your first firm/tenant and platform admin user.
#
# Run as root after 03_admin_safety_tools.sh
# =============================================================================
set -euo pipefail
APP_USER="jfmsrv01"
APP_BASE="/opt/jfmsrv01/app"
SCRIPTS_BASE="/opt/jfmsrv01/scripts"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "Run as root."
# =============================================================================
# Collect details interactively
# =============================================================================
echo ""
echo -e "${BLUE}=== First Firm Setup ===${NC}"
echo "This creates your first firm and a platform admin user."
echo "Press ENTER to accept defaults shown in [brackets]."
echo ""
read -rp "Firm name [Demo Law Firm]: " FIRM_NAME
FIRM_NAME="${FIRM_NAME:-Demo Law Firm}"
read -rp "Firm slug (URL-safe, no spaces) [demo-law-firm]: " FIRM_SLUG
FIRM_SLUG="${FIRM_SLUG:-demo-law-firm}"
read -rp "Jurisdiction [WA]: " JURISDICTION
JURISDICTION="${JURISDICTION:-WA}"
read -rp "Platform admin email [admin@jfmsrv011]: " ADMIN_EMAIL
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@jfmsrv011}"
read -rp "Platform admin name [Platform Admin]: " ADMIN_NAME
ADMIN_NAME="${ADMIN_NAME:-Platform Admin}"
# Generate a secure initial password
ADMIN_PASSWORD=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 20)
echo ""
info "Platform admin password (change after first login): $ADMIN_PASSWORD"
echo ""
# =============================================================================
# Create firm and admin via Artisan tinker
# =============================================================================
info "Creating firm: $FIRM_NAME"
sudo -u "$APP_USER" php "$APP_BASE/artisan" tinker --execute="
use App\Models\Firm;
use App\Models\User;
use App\Models\FeatureFlag;
use Illuminate\Support\Facades\Hash;
// Create firm
\$firm = Firm::updateOrCreate(
['slug' => '$FIRM_SLUG'],
[
'name' => '$FIRM_NAME',
'slug' => '$FIRM_SLUG',
'status' => 'active',
'jurisdiction' => '$JURISDICTION',
'country' => 'AU',
'timezone' => 'Australia/Perth',
'plan' => 'enterprise',
'ai_privacy_tier' => '2',
'storage_quota_gb' => 50,
'mfa_required' => false,
'settings' => [],
]
);
echo 'Firm created: ID=' . \$firm->id . PHP_EOL;
// Create platform admin (not attached to a firm)
\$admin = User::updateOrCreate(
['email' => '$ADMIN_EMAIL'],
[
'name' => '$ADMIN_NAME',
'email' => '$ADMIN_EMAIL',
'password' => Hash::make('$ADMIN_PASSWORD'),
'firm_id' => null,
'is_platform_admin' => true,
'status' => 'active',
'email_verified_at' => now(),
]
);
echo 'Platform admin created: ID=' . \$admin->id . PHP_EOL;
// Create firm admin user
\$firmAdmin = User::updateOrCreate(
['email' => 'firmadmin@' . '$FIRM_SLUG' . '.local'],
[
'name' => '$FIRM_NAME Admin',
'email' => 'firmadmin@' . '$FIRM_SLUG' . '.local',
'password' => Hash::make('$ADMIN_PASSWORD'),
'firm_id' => \$firm->id,
'is_platform_admin' => false,
'status' => 'active',
'email_verified_at' => now(),
]
);
echo 'Firm admin created: ID=' . \$firmAdmin->id . PHP_EOL;
// Enable Level 1 features for this firm
\$level1Features = ['transcription', 'transcript_viewer', 'matters', 'audit_log', 'notifications', 'backups'];
foreach (\$level1Features as \$feature) {
FeatureFlag::updateOrCreate(
['scope' => 'firm', 'firm_id' => \$firm->id, 'feature' => \$feature],
['enabled' => true]
);
}
echo 'Level 1 features enabled for firm' . PHP_EOL;
echo 'Done.' . PHP_EOL;
" 2>&1
success "Firm and users created"
# Create storage directories for the new firm
info "Creating storage directories for firm"
FIRM_ID=$(sudo -u "$APP_USER" php "$APP_BASE/artisan" tinker --execute="
echo App\Models\Firm::where('slug','$FIRM_SLUG')->value('id');
" 2>/dev/null | tail -1)
if [[ -n "$FIRM_ID" && "$FIRM_ID" =~ ^[0-9]+$ ]]; then
bash "$SCRIPTS_BASE/create_firm_storage.sh" "$FIRM_ID"
success "Storage created for firm ID: $FIRM_ID"
else
warn "Could not determine firm ID automatically. Run manually:"
warn " sudo -u $APP_USER $SCRIPTS_BASE/create_firm_storage.sh <firm_id>"
fi
# =============================================================================
# Save setup summary
# =============================================================================
SUMMARY_FILE="/root/first_firm_setup.txt"
cat > "$SUMMARY_FILE" <<EOF
# JFM Server 01 - First Firm Setup
# Created: $(date)
Firm name: $FIRM_NAME
Firm slug: $FIRM_SLUG
Firm ID: ${FIRM_ID:-unknown}
Jurisdiction: $JURISDICTION
Plan: enterprise (all features available)
Platform admin:
Email: $ADMIN_EMAIL
Password: $ADMIN_PASSWORD (CHANGE ON FIRST LOGIN)
Firm admin:
Email: firmadmin@${FIRM_SLUG}.local
Password: $ADMIN_PASSWORD (CHANGE ON FIRST LOGIN)
NOTE: Both accounts use the same initial password.
Change both passwords immediately after first login.
EOF
chmod 600 "$SUMMARY_FILE"
success "Setup summary saved to $SUMMARY_FILE (root-readable only)"
# =============================================================================
# Final summary
# =============================================================================
success "=== FIRST FIRM SETUP COMPLETE ==="
echo ""
echo -e "${GREEN}Your platform is ready.${NC}"
echo ""
echo "Firm: $FIRM_NAME ($FIRM_SLUG)"
echo "Platform admin: $ADMIN_EMAIL"
echo "Initial password: $ADMIN_PASSWORD"
echo ""
echo -e "${YELLOW}Change both user passwords immediately after first login.${NC}"
echo ""
echo "Build status:"
echo " [x] Phase 0 - Server provisioning and hardening"
echo " [x] Phase 1 - Base services (Nginx, PostgreSQL, Redis, ClamAV, PHP, Python)"
echo " [x] Phase 2 - Storage layout and cleanup jobs"
echo " [x] Phase 3 - Application skeleton (5 foundations, migrations, events)"
echo " [x] Phase 4 - Admin safety tools (backup, smoke test, patch workflow)"
echo " [x] Phase 5 - First firm created"
echo ""
echo " [ ] Phase 5 (app) - Manual audio upload and transcription MVP"
echo " [ ] Phase 6 - Word template manager and document generation"
echo ""
echo "Next: refer to the master plan Phase 1 build list to continue."
echo "Run smoke test at any time: sudo bash $SCRIPTS_BASE/smoke_test.sh"