Compare commits
36 Commits
version/01.04.00
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a8f96beb | |||
| 4ee155c8f3 | |||
| 63ffd7ea28 | |||
| d7e6ec338e | |||
| 162298f8f9 | |||
| be03793478 | |||
| 4d4bd0c906 | |||
| b209d019a9 | |||
| 181d5f1450 | |||
| 1bbba00200 | |||
| 54a9f10630 | |||
| 4ec90d38f2 | |||
| 0e385fcb9c | |||
| 5e815dd945 | |||
| c9ea9d66a0 | |||
| d307630e99 | |||
| 1983d1c4ef | |||
| e01a08c664 | |||
| 3ca55ac09c | |||
| 23459570d8 | |||
| 3f73e680f0 | |||
| 353ed547f7 | |||
| c9d529f412 | |||
| 78c41a70ca | |||
| a772f4b872 | |||
| e6ba7ade71 | |||
| f9eb1153e6 | |||
| 0e9f4888c9 | |||
| c72f679cfb | |||
| 0bfca03942 | |||
| aea5d95f09 | |||
| eeaef928b5 | |||
| 4ce4814e50 | |||
| bc0fb961b5 | |||
| 9136156034 | |||
| 8cee50d350 |
@@ -0,0 +1,903 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# This file is part of a Moko Consulting project.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow.Template
|
||||||
|
# INGROUP: MokoStandards.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||||
|
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
|
||||||
|
# VERSION: 04.06.00
|
||||||
|
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
|
||||||
|
|
||||||
|
name: "Joomla: Extension CI"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dev/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-validate:
|
||||||
|
name: Lint & Validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Setup mokocli tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||||
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
|
run: |
|
||||||
|
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||||
|
echo "mokocli already available on runner — skipping clone"
|
||||||
|
else
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||||
|
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--prefer-dist \
|
||||||
|
--optimize-autoloader
|
||||||
|
else
|
||||||
|
echo "No composer.json found — skipping dependency install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PHP syntax check
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
for DIR in src/ htdocs/; do
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
FOUND=1
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
OUTPUT=$(php -l "$FILE" 2>&1)
|
||||||
|
if echo "$OUTPUT" | grep -q "Parse error"; then
|
||||||
|
echo "::error file=${FILE}::${OUTPUT}"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DIR" -name "*.php" -print0)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: XML manifest validation
|
||||||
|
run: |
|
||||||
|
echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find the extension manifest (XML with <extension tag)
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla extension manifest found (XML file with \`<extension\` tag)." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Validate well-formed XML
|
||||||
|
php -r "
|
||||||
|
\$xml = @simplexml_load_file('$MANIFEST');
|
||||||
|
if (\$xml === false) {
|
||||||
|
echo 'INVALID';
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'VALID';
|
||||||
|
" > /tmp/xml_result 2>&1
|
||||||
|
XML_RESULT=$(cat /tmp/xml_result)
|
||||||
|
if [ "$XML_RESULT" != "VALID" ]; then
|
||||||
|
echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required tags: name, version, author
|
||||||
|
for TAG in name version author; do
|
||||||
|
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
|
||||||
|
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Namespace is required for components/plugins but not packages
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
|
||||||
|
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check language files referenced in manifest
|
||||||
|
run: |
|
||||||
|
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$MANIFEST" ]; then
|
||||||
|
# Extract language file references from manifest
|
||||||
|
LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
if [ -z "$LANG_FILES" ]; then
|
||||||
|
echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
while IFS= read -r LANG_FILE; do
|
||||||
|
LANG_FILE=$(echo "$LANG_FILE" | xargs)
|
||||||
|
if [ -z "$LANG_FILE" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Check in common locations
|
||||||
|
FOUND=0
|
||||||
|
for BASE in "." "src" "htdocs"; do
|
||||||
|
if [ -f "${BASE}/${LANG_FILE}" ]; then
|
||||||
|
FOUND=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$FOUND" -eq 0 ]; then
|
||||||
|
echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
done <<< "$LANG_FILES"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check index.html files in directories
|
||||||
|
run: |
|
||||||
|
echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=0
|
||||||
|
CHECKED=0
|
||||||
|
|
||||||
|
for DIR in src/ htdocs/; do
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
while IFS= read -r -d '' SUBDIR; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if [ ! -f "${SUBDIR}/index.html" ]; then
|
||||||
|
echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DIR" -type d -print0)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${CHECKED}" -eq 0 ]; then
|
||||||
|
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "${MISSING}" -gt 0 ]; then
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check config.xml and access.xml for components
|
||||||
|
run: |
|
||||||
|
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find all component manifests (XML with type="component")
|
||||||
|
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$COMP_MANIFESTS" ]; then
|
||||||
|
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $COMP_MANIFESTS; do
|
||||||
|
COMP_DIR=$(dirname "$MANIFEST")
|
||||||
|
COMP_NAME=$(basename "$COMP_DIR")
|
||||||
|
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check access.xml exists
|
||||||
|
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$ACCESS_FILE" ]; then
|
||||||
|
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
for ACTION in core.admin core.manage; do
|
||||||
|
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||||
|
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check config.xml exists
|
||||||
|
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$CONFIG_FILE" ]; then
|
||||||
|
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SQL schema validation
|
||||||
|
run: |
|
||||||
|
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find SQL files in source/htdocs
|
||||||
|
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$SQL_FILES" ]; then
|
||||||
|
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for FILE in $SQL_FILES; do
|
||||||
|
# Basic syntax check: balanced parentheses, no empty files
|
||||||
|
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||||
|
if [ "$SIZE" -eq 0 ]; then
|
||||||
|
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for common SQL errors
|
||||||
|
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||||
|
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check update SQL files follow version numbering pattern
|
||||||
|
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$UPDATE_DIR" ]; then
|
||||||
|
BAD_NAMES=0
|
||||||
|
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||||
|
[ ! -f "$UFILE" ] && continue
|
||||||
|
BASENAME=$(basename "$UFILE" .sql)
|
||||||
|
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||||
|
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
BAD_NAMES=$((BAD_NAMES + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||||
|
ERRORS=$((ERRORS + BAD_NAMES))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Manifest file references check
|
||||||
|
run: |
|
||||||
|
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
# Check <filename> references
|
||||||
|
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FILENAMES; do
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check <folder> references
|
||||||
|
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FOLDERS; do
|
||||||
|
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FILES; do
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Form XML validation
|
||||||
|
run: |
|
||||||
|
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$FORM_FILES" ]; then
|
||||||
|
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
for FILE in $FORM_FILES; do
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
# Check for valid Joomla form structure
|
||||||
|
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deprecated Joomla API check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in source/ src/ htdocs/; do
|
||||||
|
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||||
|
PATTERNS=(
|
||||||
|
'JFactory::'
|
||||||
|
'JText::'
|
||||||
|
'JHtml::'
|
||||||
|
'JRoute::'
|
||||||
|
'JUri::'
|
||||||
|
'JLog::'
|
||||||
|
'JTable::'
|
||||||
|
'JInput'
|
||||||
|
'CMSFactory::\$application'
|
||||||
|
'JApplicationCms'
|
||||||
|
)
|
||||||
|
|
||||||
|
for PATTERN in "${PATTERNS[@]}"; do
|
||||||
|
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
COUNT=$(echo "$HITS" | wc -l)
|
||||||
|
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + COUNT))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Template output escaping check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$TMPL_FILES" ]; then
|
||||||
|
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for FILE in $TMPL_FILES; do
|
||||||
|
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||||
|
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$UNESCAPED" ]; then
|
||||||
|
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||||
|
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + HITS))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for echo without escaping in template context
|
||||||
|
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$RAW_ECHO" ]; then
|
||||||
|
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||||
|
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + HITS))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Namespace consistency check
|
||||||
|
run: |
|
||||||
|
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find component/plugin manifests with <namespace> tags
|
||||||
|
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$MANIFESTS" ]; then
|
||||||
|
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $MANIFESTS; do
|
||||||
|
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||||
|
[ -z "$NS_PATH" ] && continue
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check PHP files have matching namespace
|
||||||
|
while IFS= read -r -d '' PHP_FILE; do
|
||||||
|
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||||
|
[ -z "$FILE_NS" ] && continue
|
||||||
|
|
||||||
|
# Namespace should start with the manifest namespace path
|
||||||
|
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||||
|
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SPDX license header check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=0
|
||||||
|
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in source/ src/ htdocs/; do
|
||||||
|
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
TOTAL=0
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
TOTAL=$((TOTAL + 1))
|
||||||
|
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||||
|
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$MISSING" -gt 0 ]; then
|
||||||
|
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Service provider check
|
||||||
|
run: |
|
||||||
|
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$PROVIDERS" ]; then
|
||||||
|
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for FILE in $PROVIDERS; do
|
||||||
|
# Must return a ServiceProviderInterface
|
||||||
|
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must have return statement
|
||||||
|
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
release-readiness:
|
||||||
|
name: Release Readiness Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate release readiness
|
||||||
|
run: |
|
||||||
|
echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Extract version from README.md
|
||||||
|
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
|
||||||
|
if [ -z "$README_VERSION" ]; then
|
||||||
|
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the extension manifest
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check <version> matches README VERSION
|
||||||
|
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||||
|
if [ -z "$MANIFEST_VERSION" ]; then
|
||||||
|
echo "No \`<version>\` tag in manifest." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
|
||||||
|
echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check extension type, element, client attributes
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ -z "$EXT_TYPE" ]; then
|
||||||
|
echo "Missing \`type\` attribute on \`<extension>\` tag." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Element check (component/module/plugin name)
|
||||||
|
HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
|
||||||
|
if [ "$HAS_ELEMENT" -eq 0 ]; then
|
||||||
|
echo "Missing \`<element>\` or \`<name>\` in manifest." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Client attribute for site/admin modules and plugins
|
||||||
|
if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
|
||||||
|
HAS_CLIENT=$(grep -cP '<extension[^>]*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
|
||||||
|
if [ "$HAS_CLIENT" -eq 0 ]; then
|
||||||
|
echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check updates.xml exists
|
||||||
|
if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
|
||||||
|
echo "Update XML present." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check CHANGELOG.md exists
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ $ERRORS -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Tests (PHP ${{ matrix.php }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-validate
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
php: ['8.2', '8.3']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP ${{ matrix.php }}
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--prefer-dist \
|
||||||
|
--optimize-autoloader
|
||||||
|
else
|
||||||
|
echo "No composer.json found — skipping dependency install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
|
||||||
|
vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
|
||||||
|
EXIT=${PIPESTATUS[0]}
|
||||||
|
if [ $EXIT -eq 0 ]; then
|
||||||
|
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
exit $EXIT
|
||||||
|
else
|
||||||
|
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
static-analysis:
|
||||||
|
name: PHPStan Analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-validate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install PHPStan
|
||||||
|
run: |
|
||||||
|
if ! command -v vendor/bin/phpstan &> /dev/null; then
|
||||||
|
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
|
||||||
|
composer global require phpstan/phpstan --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run PHPStan
|
||||||
|
run: |
|
||||||
|
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
|
||||||
|
PHPSTAN="vendor/bin/phpstan"
|
||||||
|
if [ ! -f "$PHPSTAN" ]; then
|
||||||
|
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine source directory
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in src/ htdocs/ lib/; do
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
SRC_DIR="$DIR"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use repo phpstan.neon if present, otherwise use baseline config
|
||||||
|
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
|
||||||
|
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
|
||||||
|
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
ARGS="$ARGS --level=3"
|
||||||
|
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
|
||||||
|
EXIT=${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
if [ $EXIT -eq 0 ]; then
|
||||||
|
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
|
||||||
|
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
exit $EXIT
|
||||||
|
|
||||||
|
pre-release:
|
||||||
|
name: Build RC Pre-Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-and-validate, test]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger pre-release build
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.head_ref }}
|
||||||
|
run: |
|
||||||
|
curl -s -X POST \
|
||||||
|
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||||
|
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
|
||||||
# VERSION: 04.07.00
|
|
||||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
|
||||||
|
|
||||||
name: "Universal: Deploy to Dev (Manual)"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
clear_remote:
|
|
||||||
description: 'Delete all remote files before uploading'
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: SFTP Deploy to Dev
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
run: |
|
|
||||||
php -v && composer --version
|
|
||||||
|
|
||||||
- name: Setup MokoStandards tools
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
|
||||||
run: |
|
|
||||||
git clone --depth 1 --branch main --quiet \
|
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
|
||||||
/tmp/mokostandards-api 2>/dev/null || true
|
|
||||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
|
||||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check FTP configuration
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
|
||||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
|
||||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
|
||||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
|
||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
REMOTE="${PATH_VAR%/}"
|
|
||||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
[ -z "$PORT" ] && PORT="22"
|
|
||||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
|
||||||
if: steps.check.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
|
||||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
|
||||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
|
||||||
run: |
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
|
||||||
|
|
||||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
|
||||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
|
|
||||||
if [ -n "$SFTP_KEY" ]; then
|
|
||||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
|
||||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
|
||||||
|
|
||||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
|
||||||
else
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
|
||||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.04.00
|
# VERSION: 01.08.04
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Validation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||||
|
|
||||||
|
name: "Joomla: Metadata Validation"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-metadata:
|
||||||
|
name: "Validate Joomla Metadata"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup mokocli tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||||
|
echo Using pre-installed /opt/mokocli
|
||||||
|
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/mokocli
|
||||||
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||||
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate metadata against Joomla manifest
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||||
|
--path . \
|
||||||
|
--token "${GITEA_TOKEN}" \
|
||||||
|
--org "${GITEA_ORG}" \
|
||||||
|
--repo "${GITEA_REPO}" \
|
||||||
|
--api-base "${GITEA_URL}/api/v1" \
|
||||||
|
--ci
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer satisfaction — post-service surveys, NPS scoring, technician ratings.
|
||||||
|
*/
|
||||||
|
class CustomerSatisfactionHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Record a post-service survey response.
|
||||||
|
*/
|
||||||
|
public static function recordSurvey(int $workOrderId, int $contactId, int $rating, ?string $comment = null): object
|
||||||
|
{
|
||||||
|
if ($rating < 1 || $rating > 5) {
|
||||||
|
throw new \InvalidArgumentException('Rating must be 1-5.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
// Prevent duplicate surveys per work order
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('id')
|
||||||
|
->from('#__mokosuitefield_surveys')
|
||||||
|
->where('work_order_id = ' . (int) $workOrderId)
|
||||||
|
->where('contact_id = ' . (int) $contactId));
|
||||||
|
|
||||||
|
if ($db->loadResult()) {
|
||||||
|
return (object) ['success' => false, 'error' => 'Survey already submitted for this work order'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = \Joomla\Filter\InputFilter::getInstance();
|
||||||
|
|
||||||
|
$survey = (object) [
|
||||||
|
'work_order_id' => $workOrderId,
|
||||||
|
'contact_id' => $contactId,
|
||||||
|
'rating' => $rating,
|
||||||
|
'comment' => $comment !== null ? $filter->clean($comment, 'STRING') : null,
|
||||||
|
'nps_score' => $rating >= 4 ? 'promoter' : ($rating >= 3 ? 'passive' : 'detractor'),
|
||||||
|
'created_at' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuitefield_surveys', $survey, 'id');
|
||||||
|
|
||||||
|
return (object) ['success' => true, 'survey_id' => (int) $survey->id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get NPS (Net Promoter Score) for a period.
|
||||||
|
*/
|
||||||
|
public static function getNps(string $from = '', string $to = ''): object
|
||||||
|
{
|
||||||
|
$from = $from ?: date('Y-01-01');
|
||||||
|
$to = $to ?: date('Y-m-d');
|
||||||
|
|
||||||
|
if (!\DateTime::createFromFormat('Y-m-d', $from) || !\DateTime::createFromFormat('Y-m-d', $to)) {
|
||||||
|
throw new \InvalidArgumentException('Date parameters must be Y-m-d format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COUNT(*) AS total_responses')
|
||||||
|
->select('SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS promoters')
|
||||||
|
->select('SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) AS passives')
|
||||||
|
->select('SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END) AS detractors')
|
||||||
|
->select('AVG(rating) AS avg_rating')
|
||||||
|
->from('#__mokosuitefield_surveys')
|
||||||
|
->where('DATE(created_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
|
||||||
|
|
||||||
|
$stats = $db->loadObject();
|
||||||
|
$total = (int) ($stats->total_responses ?? 0);
|
||||||
|
$promoterPct = $total > 0 ? (int) $stats->promoters / $total * 100 : 0;
|
||||||
|
$detractorPct = $total > 0 ? (int) $stats->detractors / $total * 100 : 0;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'nps' => round($promoterPct - $detractorPct),
|
||||||
|
'total_responses' => $total,
|
||||||
|
'promoters' => (int) ($stats->promoters ?? 0),
|
||||||
|
'passives' => (int) ($stats->passives ?? 0),
|
||||||
|
'detractors' => (int) ($stats->detractors ?? 0),
|
||||||
|
'avg_rating' => round((float) ($stats->avg_rating ?? 0), 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get technician satisfaction rankings.
|
||||||
|
*/
|
||||||
|
public static function getTechnicianRankings(int $limit = 20): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('t.id AS tech_id, cd.name AS tech_name')
|
||||||
|
->select('COUNT(s.id) AS survey_count')
|
||||||
|
->select('AVG(s.rating) AS avg_rating')
|
||||||
|
->select('SUM(CASE WHEN s.rating >= 4 THEN 1 ELSE 0 END) AS five_star_count')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_surveys', 's'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = s.work_order_id')
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||||
|
->group('t.id, cd.name')
|
||||||
|
->having('COUNT(s.id) >= 3')
|
||||||
|
->order('avg_rating DESC'), 0, min(max(1, $limit), 100));
|
||||||
|
|
||||||
|
$results = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($results as &$r) {
|
||||||
|
$r->avg_rating = round((float) $r->avg_rating, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS tracking — vehicle location history, geofence alerts, drive time analysis.
|
||||||
|
*/
|
||||||
|
class GpsTrackingHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Record a GPS ping for a vehicle.
|
||||||
|
*/
|
||||||
|
public static function recordPing(int $vehicleId, float $latitude, float $longitude, float $speed = 0): bool
|
||||||
|
{
|
||||||
|
if ($latitude < -90 || $latitude > 90 || $longitude < -180 || $longitude > 180) {
|
||||||
|
throw new \InvalidArgumentException('Invalid GPS coordinates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$ping = (object) [
|
||||||
|
'vehicle_id' => $vehicleId,
|
||||||
|
'latitude' => round($latitude, 6),
|
||||||
|
'longitude' => round($longitude, 6),
|
||||||
|
'speed_mph' => max(0, round($speed, 1)),
|
||||||
|
'recorded_at'=> Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuitefield_gps_pings', $ping);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest position for all active vehicles.
|
||||||
|
*/
|
||||||
|
public static function getFleetPositions(): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate')
|
||||||
|
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
|
||||||
|
->select('cd.name AS assigned_tech')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||||
|
->join('LEFT', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1'
|
||||||
|
. ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at'
|
||||||
|
. ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2'
|
||||||
|
. ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp'
|
||||||
|
. ' ON gp.vehicle_id = v.id')
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id')
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||||
|
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
|
||||||
|
->order('v.name ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get drive history for a vehicle on a specific date.
|
||||||
|
*/
|
||||||
|
public static function getDriveHistory(int $vehicleId, string $date = ''): array
|
||||||
|
{
|
||||||
|
$date = $date ?: date('Y-m-d');
|
||||||
|
|
||||||
|
if (!\DateTime::createFromFormat('Y-m-d', $date)) {
|
||||||
|
throw new \InvalidArgumentException('Date must be Y-m-d format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_gps_pings', 'gp'))
|
||||||
|
->where('gp.vehicle_id = ' . (int) $vehicleId)
|
||||||
|
->where('DATE(gp.recorded_at) = ' . $db->quote($date))
|
||||||
|
->order('gp.recorded_at ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vehicles currently exceeding speed threshold.
|
||||||
|
*/
|
||||||
|
public static function getSpeeding(float $thresholdMph = 70): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate')
|
||||||
|
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
|
||||||
|
->select('cd.name AS assigned_tech')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||||
|
->join('INNER', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1'
|
||||||
|
. ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at'
|
||||||
|
. ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2'
|
||||||
|
. ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp'
|
||||||
|
. ' ON gp.vehicle_id = v.id')
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id')
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||||
|
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
|
||||||
|
->where('gp.speed_mph > ' . (float) $thresholdMph)
|
||||||
|
->where('gp.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)')
|
||||||
|
->order('gp.speed_mph DESC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety checklists — pre-job safety checks, compliance tracking, incident prevention.
|
||||||
|
*/
|
||||||
|
class SafetyChecklistHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a safety checklist for a work order.
|
||||||
|
*/
|
||||||
|
public static function createChecklist(int $woId, string $trade): int
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$now = Factory::getDate()->toSql();
|
||||||
|
|
||||||
|
$items = self::getDefaultItems($trade);
|
||||||
|
|
||||||
|
$checklist = (object) [
|
||||||
|
'wo_id' => $woId,
|
||||||
|
'trade' => $trade,
|
||||||
|
'status' => 'pending',
|
||||||
|
'created' => $now,
|
||||||
|
'created_by' => Factory::getApplication()->getIdentity()->id,
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuitefield_safety_checklists', $checklist, 'id');
|
||||||
|
$checklistId = (int) $checklist->id;
|
||||||
|
|
||||||
|
foreach ($items as $i => $item) {
|
||||||
|
$db->insertObject('#__mokosuitefield_safety_checklist_items', (object) [
|
||||||
|
'checklist_id' => $checklistId,
|
||||||
|
'item_text' => $item,
|
||||||
|
'checked' => 0,
|
||||||
|
'ordering' => $i + 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $checklistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a checklist item.
|
||||||
|
*/
|
||||||
|
public static function checkItem(int $itemId, bool $passed, string $notes = ''): bool
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
// Verify item belongs to a pending checklist and is not already checked
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('sci.id, sci.checked, sc.status AS checklist_status')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci.checklist_id')
|
||||||
|
->where('sci.id = ' . (int) $itemId));
|
||||||
|
$existing = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$existing || $existing->checklist_status !== 'pending' || (int) $existing->checked === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = (object) [
|
||||||
|
'id' => $itemId,
|
||||||
|
'checked' => 1,
|
||||||
|
'passed' => $passed ? 1 : 0,
|
||||||
|
'notes' => $notes,
|
||||||
|
'checked_at' => Factory::getDate()->toSql(),
|
||||||
|
'checked_by' => Factory::getApplication()->getIdentity()->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->updateObject('#__mokosuitefield_safety_checklist_items', $update, 'id');
|
||||||
|
|
||||||
|
// Auto-complete checklist if all items are checked
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('sc.id, COUNT(sci2.id) AS total, SUM(CASE WHEN sci2.checked = 1 THEN 1 ELSE 0 END) AS done')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci2'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci2.checklist_id')
|
||||||
|
->where('sci2.id = ' . (int) $itemId)
|
||||||
|
->group('sc.id'));
|
||||||
|
$progress = $db->loadObject();
|
||||||
|
|
||||||
|
if ($progress && (int) $progress->done === (int) $progress->total) {
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->update('#__mokosuitefield_safety_checklists')
|
||||||
|
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
|
||||||
|
->where('id = ' . (int) $progress->id));
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get checklist completion status for a work order.
|
||||||
|
*/
|
||||||
|
public static function getStatus(int $woId): ?object
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('sc.id, sc.status')
|
||||||
|
->select('COUNT(sci.id) AS total_items')
|
||||||
|
->select('SUM(CASE WHEN sci.checked = 1 THEN 1 ELSE 0 END) AS checked_items')
|
||||||
|
->select('SUM(CASE WHEN sci.checked = 1 AND sci.passed = 0 THEN 1 ELSE 0 END) AS failed_items')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_safety_checklists', 'sc'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci') . ' ON sci.checklist_id = sc.id')
|
||||||
|
->where('sc.wo_id = ' . (int) $woId)
|
||||||
|
->group('sc.id')
|
||||||
|
->order('sc.created DESC'));
|
||||||
|
|
||||||
|
$status = $db->loadObject();
|
||||||
|
if (!$status) return null;
|
||||||
|
|
||||||
|
$status->complete = (int) $status->checked_items === (int) $status->total_items;
|
||||||
|
$status->all_passed = (int) $status->failed_items === 0;
|
||||||
|
$status->safe_to_proceed = $status->complete && $status->all_passed;
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default safety items by trade.
|
||||||
|
*/
|
||||||
|
private static function getDefaultItems(string $trade): array
|
||||||
|
{
|
||||||
|
$common = [
|
||||||
|
'PPE worn (gloves, safety glasses, boots)',
|
||||||
|
'Work area inspected for hazards',
|
||||||
|
'Tools and equipment in good condition',
|
||||||
|
'Fire extinguisher accessible',
|
||||||
|
'Emergency exits identified',
|
||||||
|
];
|
||||||
|
|
||||||
|
$tradeSpecific = match ($trade) {
|
||||||
|
'electrical' => ['Lockout/tagout verified', 'Voltage tester functional', 'Grounding confirmed', 'Arc flash boundaries marked'],
|
||||||
|
'plumbing' => ['Water supply shut off', 'Gas lines identified and marked', 'Asbestos check for older buildings', 'Drain protection in place'],
|
||||||
|
'hvac' => ['Refrigerant handling certification verified', 'Electrical isolation confirmed', 'Ductwork supports inspected', 'Ladder/scaffold secured'],
|
||||||
|
'general' => ['Work permit obtained if required', 'Material Safety Data Sheets reviewed'],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return array_merge($common, $tradeSpecific);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technician skill matrix — certifications, skill-based dispatch matching, expiry tracking.
|
||||||
|
*/
|
||||||
|
class TechnicianSkillHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get skill matrix for all active technicians.
|
||||||
|
*/
|
||||||
|
public static function getSkillMatrix(): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('t.id AS tech_id, cd.name AS tech_name')
|
||||||
|
->select('GROUP_CONCAT(ts.skill_name ORDER BY ts.skill_name SEPARATOR ", ") AS skills')
|
||||||
|
->select('COUNT(ts.id) AS skill_count')
|
||||||
|
->select('SUM(CASE WHEN ts.certification_expires IS NOT NULL AND ts.certification_expires < NOW() THEN 1 ELSE 0 END) AS expired_certs')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') . ' ON ts.tech_id = t.id')
|
||||||
|
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
|
||||||
|
->group('t.id')
|
||||||
|
->order('cd.name ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find best technician match for a work order based on required skills.
|
||||||
|
*/
|
||||||
|
public static function findBestMatch(array $requiredSkills, string $date = ''): array
|
||||||
|
{
|
||||||
|
if (empty($requiredSkills)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $date ?: date('Y-m-d');
|
||||||
|
if (!\DateTime::createFromFormat('Y-m-d', $date)) {
|
||||||
|
throw new \InvalidArgumentException('Date must be Y-m-d format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$skillPlaceholders = implode(',', array_map(fn($s) => $db->quote($s), $requiredSkills));
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('t.id AS tech_id, cd.name AS tech_name, t.hourly_rate')
|
||||||
|
->select('COUNT(DISTINCT ts.skill_name) AS matching_skills')
|
||||||
|
->select((string) count($requiredSkills) . ' AS required_skills')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitefield_tech_skills', 'ts')
|
||||||
|
. ' ON ts.tech_id = t.id AND ts.skill_name IN (' . $skillPlaceholders . ')'
|
||||||
|
. ' AND (ts.certification_expires IS NULL OR ts.certification_expires > ' . $db->quote($date) . ')')
|
||||||
|
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
|
||||||
|
->group('t.id')
|
||||||
|
->order('matching_skills DESC, t.hourly_rate ASC'));
|
||||||
|
|
||||||
|
$matches = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($matches as &$m) {
|
||||||
|
$m->match_pct = round((int) $m->matching_skills / (int) $m->required_skills * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get expiring certifications within N days.
|
||||||
|
*/
|
||||||
|
public static function getExpiringCertifications(int $days = 30): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$cutoff = date('Y-m-d', strtotime("+{$days} days"));
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('ts.id, ts.skill_name, ts.certification_number, ts.certification_expires')
|
||||||
|
->select('cd.name AS tech_name, cd.email_to')
|
||||||
|
->from($db->quoteName('#__mokosuitefield_tech_skills', 'ts'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = ts.tech_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||||
|
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
|
||||||
|
->where('ts.certification_expires IS NOT NULL')
|
||||||
|
->where('ts.certification_expires BETWEEN NOW() AND ' . $db->quote($cutoff))
|
||||||
|
->order('ts.certification_expires ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,8 +47,9 @@ class TruckStockHelper
|
|||||||
$db->setQuery($db->getQuery(true)
|
$db->setQuery($db->getQuery(true)
|
||||||
->update('#__mokosuitefield_truck_stock')
|
->update('#__mokosuitefield_truck_stock')
|
||||||
->set('quantity = quantity - ' . (float) $qty)
|
->set('quantity = quantity - ' . (float) $qty)
|
||||||
->where('vehicle_id = ' . $vehicleId)
|
->where('vehicle_id = ' . (int) $vehicleId)
|
||||||
->where('product_id = ' . $productId));
|
->where('product_id = ' . (int) $productId)
|
||||||
|
->where('quantity >= ' . (float) $qty));
|
||||||
$db->execute();
|
$db->execute();
|
||||||
|
|
||||||
return $db->getAffectedRows() > 0;
|
return $db->getAffectedRows() > 0;
|
||||||
@@ -58,7 +59,11 @@ class TruckStockHelper
|
|||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
$db->setQuery("INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked) VALUES ({$vehicleId}, {$productId}, {$qty}, CURDATE()) ON DUPLICATE KEY UPDATE quantity = quantity + {$qty}, last_restocked = CURDATE()");
|
$db->setQuery(
|
||||||
|
'INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked)'
|
||||||
|
. ' VALUES (' . (int) $vehicleId . ', ' . (int) $productId . ', ' . (float) $qty . ', CURDATE())'
|
||||||
|
. ' ON DUPLICATE KEY UPDATE quantity = quantity + ' . (float) $qty . ', last_restocked = CURDATE()'
|
||||||
|
);
|
||||||
$db->execute();
|
$db->execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ class WarrantyHelper
|
|||||||
if (empty($warranty->id)) return (object) ['success' => false, 'error' => 'Equipment not found'];
|
if (empty($warranty->id)) return (object) ['success' => false, 'error' => 'Equipment not found'];
|
||||||
if (!$warranty->under_warranty) return (object) ['success' => false, 'error' => 'Warranty expired'];
|
if (!$warranty->under_warranty) return (object) ['success' => false, 'error' => 'Warranty expired'];
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
// Verify work order exists and is linked to this equipment
|
// Verify work order exists and is linked to this equipment
|
||||||
$db->setQuery($db->getQuery(true)->select('id')->from('#__mokosuitefield_work_orders')
|
$db->setQuery($db->getQuery(true)->select('id')->from('#__mokosuitefield_work_orders')
|
||||||
->where('id = ' . (int) $woId));
|
->where('id = ' . (int) $woId));
|
||||||
if (!(int) $db->loadResult()) {
|
if (!(int) $db->loadResult()) {
|
||||||
return (object) ['success' => false, 'error' => 'Invalid work order'];
|
return (object) ['success' => false, 'error' => 'Invalid work order'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$now = Factory::getDate()->toSql();
|
$now = Factory::getDate()->toSql();
|
||||||
|
|
||||||
$claim = (object) [
|
$claim = (object) [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuite Field</name>
|
<name>Package - MokoSuite Field</name>
|
||||||
<packagename>mokosuitefield</packagename>
|
<packagename>mokosuitefield</packagename>
|
||||||
<version>01.04.00</version>
|
<version>01.08.04</version>
|
||||||
<creationDate>2026-06-12</creationDate>
|
<creationDate>2026-06-12</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
Reference in New Issue
Block a user