diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..59f7c90 --- /dev/null +++ b/.distignore @@ -0,0 +1,20 @@ +/.git +/.github +/.claude +/.wordpress-org +/docs +/tests +/node_modules +/vendor/bin +/build +.distignore +.editorconfig +.gitattributes +.gitignore +composer.json +composer.lock +phpcs.xml.dist +phpunit.xml.dist +CONTRIBUTING.md +SECURITY.md +CODE_OF_CONDUCT.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5153b80 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab + +[*.{md,yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.txt] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b837d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +/.github export-ignore +/docs export-ignore +/tests export-ignore +/.distignore export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpcs.xml.dist export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..864a4f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,75 @@ +name: Bug report +description: Something is not working as documented +labels: [ "bug" ] +body: + - type: markdown + attributes: + value: | + Before filing, please run **Procore → Connection → Test connection**. It reports + each stage separately and probes every endpoint, which identifies most problems + in one click. + + **Never paste real credentials into a public issue.** For a security problem, use + [private reporting](https://github.com/ibuilder/ProcoreWP/security/advisories/new) + instead. + + - type: textarea + id: what + attributes: + label: What happened + description: What did you expect, and what did you get instead? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Add [procore_rfis id="123"] to a page + 2. View the page as a logged-out visitor + 3. … + validations: + required: true + + - type: input + id: shortcode + attributes: + label: The shortcode or block involved + placeholder: '[procore_rfis id="123" status="open"]' + + - type: textarea + id: doctor + attributes: + label: Output of `wp procore-connect doctor` + description: > + Reports whether credentials are set without printing them, so it is safe to + share. If WP-CLI is unavailable, paste the **Procore → Status** table instead. + render: shell + + - type: textarea + id: probes + attributes: + label: Connection test results + description: > + Which stages and which endpoint probes failed? Redact any account names you would + rather not publish. + render: shell + + - type: input + id: versions + attributes: + label: Versions + placeholder: 'Procore Connect 2.0.0, WordPress 6.9, PHP 8.2' + validations: + required: true + + - type: checkboxes + id: confirmations + attributes: + label: Before submitting + options: + - label: I ran the connection test and the failure is not a missing Procore tool permission + - label: I checked the page as an administrator, where the real error message is shown + - label: This report contains no real credentials + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..efb8632 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://ibuilder.github.io/ProcoreWP/ + about: Installation, authentication, the shortcode reference and troubleshooting. + - name: Report a security vulnerability + url: https://github.com/ibuilder/ProcoreWP/security/advisories/new + about: Please report privately rather than opening a public issue. + - name: Procore API documentation + url: https://developers.procore.com/documentation/introduction + about: For questions about the Procore API itself rather than this plugin. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..95f1bd1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest a capability or a new Procore endpoint +labels: [ "enhancement" ] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: Describe the goal, not just the feature. It often points at a simpler answer. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like Procore Connect to do? + validations: + required: true + + - type: input + id: endpoint + attributes: + label: Procore endpoint, if this is about new data + placeholder: 'GET /rest/v1.0/projects/{project_id}/meetings' + + - type: markdown + attributes: + value: | + If you only need one extra endpoint, you may not need to wait for a release — + `procore_connect_endpoints` registers one from a small snippet in your theme or a + site plugin. See + [the shortcode reference](https://ibuilder.github.io/ProcoreWP/shortcodes/#the-generic-reader). + + - type: textarea + id: alternatives + attributes: + label: What have you tried? + description: Filters, template overrides, `[procore_data]`, or another plugin. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6354fbb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,31 @@ +## What this changes + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation +- [ ] Maintenance / tooling + +## Checks + +- [ ] `composer run syntax` passes +- [ ] `composer run lint` passes with no errors and no warnings +- [ ] `composer run test` passes +- [ ] Added or updated tests for the behaviour this changes +- [ ] Updated `CHANGELOG.md` +- [ ] Updated `readme.txt` and `docs/` if user-facing behaviour changed + +## If this touches the API layer + +- [ ] New endpoints are registered in `src/Api/Endpoints.php` with their required Procore permission +- [ ] `'public' => true` is only set on endpoints whose payload is safe to publish +- [ ] Output goes through `Format::cell()` or an explicit `esc_*` call +- [ ] Procore error messages are not exposed to non-administrators + +## Testing done + + diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..65fb067 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,63 @@ +name: Documentation + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Let an in-progress deployment finish rather than cancelling it mid-publish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build the documentation site + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: docs + + - name: Build with Jekyll + run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" + working-directory: docs + env: + JEKYLL_ENV: production + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml new file mode 100644 index 0000000..35687a8 --- /dev/null +++ b/.github/workflows/phpcs.yml @@ -0,0 +1,58 @@ +name: Coding Standards + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + phpcs: + name: PHPCS (WordPress-Extra + WordPress-Docs) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPCS + run: ./vendor/bin/phpcs --report=full --report-checkstyle=phpcs-report.xml + + - name: Annotate results + if: failure() + run: ./vendor/bin/phpcs --report=emacs || true + + syntax: + name: PHP syntax (${{ matrix.php }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Lint every PHP file + run: | + find . -path ./vendor -prune -o -name '*.php' -print0 \ + | xargs -0 -n1 -P4 php -l diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml new file mode 100644 index 0000000..8b8044f --- /dev/null +++ b/.github/workflows/plugin-check.yml @@ -0,0 +1,49 @@ +name: Plugin Check + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + plugin-check: + name: WordPress Plugin Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + + # Build the distributable exactly as the release workflow does, so the + # check runs against what users actually install — not the dev tree. + - name: Stage the distributable + run: | + mkdir -p build/procore-connect + rsync -a --exclude-from=.distignore \ + --exclude='.git' --exclude='build' --exclude='vendor' \ + ./ build/procore-connect/ + echo "Staged files:" + find build/procore-connect -type f | sort + + - name: Run Plugin Check + uses: wordpress/plugin-check-action@v1 + with: + build-dir: ./build/procore-connect + # Target zero findings. Remove nothing from this list without a + # documented reason in the pull request. + categories: | + general + plugin_repo + security + performance + accessibility diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..93280cc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to build, e.g. v2.0.0' + required: true + +permissions: + contents: write + +jobs: + build: + name: Build and publish the release ZIP + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + # A tag that disagrees with the plugin header would ship a version users + # cannot update from, so fail loudly rather than publishing it. + - name: Verify the version is consistent + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + TAG_VERSION="${TAG#v}" + HEADER_VERSION=$(grep -oP '^\s*\*\s*Version:\s*\K[0-9.]+' procore-connect.php) + README_VERSION=$(grep -oP '^Stable tag:\s*\K[0-9.]+' readme.txt) + + echo "tag=$TAG_VERSION header=$HEADER_VERSION readme=$README_VERSION" + + if [ "$TAG_VERSION" != "$HEADER_VERSION" ] || [ "$TAG_VERSION" != "$README_VERSION" ]; then + echo "::error::Version mismatch between the tag, the plugin header and readme.txt." + exit 1 + fi + + - name: Run the checks + run: | + ./vendor/bin/phpcs + ./vendor/bin/phpunit + + - name: Stage the distributable + run: | + mkdir -p build/procore-connect + rsync -a --exclude-from=.distignore \ + --exclude='.git' --exclude='build' --exclude='vendor' \ + ./ build/procore-connect/ + + - name: Create the ZIP + run: | + cd build + zip -r "../procore-connect-${{ github.event.inputs.tag || github.ref_name }}.zip" procore-connect + cd .. + unzip -l "procore-connect-${{ github.event.inputs.tag || github.ref_name }}.zip" + + - name: Publish the release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + name: Procore Connect ${{ github.event.inputs.tag || github.ref_name }} + body_path: CHANGELOG.md + files: procore-connect-*.zip + draft: false + generate_release_notes: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3c93545 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,59 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ '7.4', '8.1', '8.3', '8.4' ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: openssl, json, mbstring + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + # Tests run against fixtures through an injected HTTP transport, so no + # Procore credentials and no network access are required. + - name: Run PHPUnit + run: ./vendor/bin/phpunit --testdox + + javascript: + name: JavaScript syntax + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + # No build step by design: the shipped editor script is the source file. + - name: Check syntax + run: | + node --check assets/js/admin.js + node --check blocks/procore/index.js + + - name: Validate block.json + run: node -e "JSON.parse(require('fs').readFileSync('blocks/procore/block.json','utf8'))" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d029f9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/vendor/ +/node_modules/ +/build/ +/.phpunit.result.cache +/.phpcs.cache +composer.lock +*.log +.DS_Store +Thumbs.db +/.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fb69693 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,128 @@ +# Changelog + +All notable changes to Procore Connect are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] — 2026-08-07 + +A complete rewrite. Version 1.x could not authenticate against the live Procore API, so +every install was non-functional regardless of configuration. + +### ⚠️ Action required when upgrading + +- **The plugin is now called Procore Connect**, with the slug `procore-connect`. The + previous name contained "wp", which wordpress.org disallows in both a plugin name and + a slug, making the directory listing impossible. The GitHub repository stays at + `ibuilder/ProcoreWP`. +- The main plugin file was renamed from `index.php` to `procore-connect.php`. WordPress + treats this as a different plugin, so **Procore Connect must be activated once** after + updating. Settings are imported automatically by the upgrade routine. +- Because 1.x never obtained a token, any stored token is discarded. **Re-run + Procore → Connection → Test connection** after upgrading. +- All 1.x shortcode tags are unchanged, and the legacy `id` attribute is still accepted + as an alias for `project_id`. Existing pages keep working. + +### Fixed + +- **Authentication targeted the wrong host.** Token requests went to + `api.procore.com/oauth/token`. Procore serves authentication from + `login.procore.com/oauth/token`, a separate origin, so a token was never issued. +- **Company scope was sent the wrong way.** The company was appended as a `company_id` + query parameter. Procore requires the `Procore-Company-Id` request header, without + which most endpoints return 401 or 403. +- **Pagination did not exist.** `get_projects()` returned only the first page while + `limit="0"` advertised "show all". +- **Sorting corrupted result sets.** `array_multisort()` over `array_column()` output + misaligns rows as soon as any record lacks the sort column. Records with no value now + sort last, in both directions, and stay attached to their own row. +- **Sparse payloads produced PHP warnings.** Procore omits fields the account cannot see; + every read is now null-safe. +- **Activation referenced a file that was never in the repository.** + `assets/css/procore-integration-default.css` did not exist, so `file_get_contents()` + warned and `copy()` silently did nothing. +- **The stylesheet loaded on every page** whether or not the plugin rendered anything. +- **No request timeout was set**, leaving requests on the 5-second default with no user + agent and no explicit SSL verification. + +### Security + +- The client secret, access token and refresh token are encrypted at rest with + AES-256-GCM, keyed from the site's own salts, and stored in a non-autoloaded option. + 1.x stored all three as plaintext in an option loaded on every request site-wide. +- The secret is never rendered back into a form field. The input shows a masked + placeholder; submitting it blank leaves the stored value untouched. +- Credentials may be defined as `PROCORE_CONNECT_CLIENT_ID` / `PROCORE_CONNECT_CLIENT_SECRET` / + `PROCORE_CONNECT_COMPANY_ID` in `wp-config.php`, taking precedence and never reaching the + database. +- Every admin action verifies a nonce and the `manage_options` capability. 1.x ran its + connection test from an unverified `$_POST` key. +- `register_setting()` now has a `sanitize_callback`. 1.x had none, so an arbitrary + array could be written to the option. +- Email addresses are suppressed by default and require two separate opt-ins to publish; + when published they pass through `antispambot()`. +- Procore error messages, which routinely name accounts and permissions, are shown to + administrators only. Visitors see a neutral notice. +- `[procore_project_data]` reads from an allow-list rather than any field in the payload. +- `Link` headers pointing off the configured API host are refused. +- The OAuth flow binds a one-time `state` value to a nonce-protected transient. + +### Added + +- **Client Credentials (DMSA) and Authorization Code** grants, selectable in the admin, + with a refresh mutex so concurrent front-end requests cannot invalidate a refresh + token — Procore revokes the old one the instant it is exchanged. +- **Response caching** with per-endpoint lifetimes, a site-wide floor that page authors + cannot undercut, group purging, and an optional hourly cron warmer. Backed by the + transient API, so persistent object caches are used automatically. +- **Rate-limit handling**: `X-Rate-Limit-*` headers are recorded and surfaced, `429` and + `503` are retried with exponential backoff plus jitter honouring `Retry-After`, and a + circuit breaker pauses requests after repeated failures. +- **Stale-cache fallback**, so a Procore outage degrades a page rather than blanking it. +- **Pagination** by following `Link: rel="next"`, with a page ceiling. +- **Sandbox and custom regional environments**, each with their own credentials. +- **Eleven new shortcodes**: `procore_rfis`, `procore_submittals`, `procore_punch_list`, + `procore_observations`, `procore_daily_logs`, `procore_change_orders`, + `procore_milestones`, `procore_vendors`, `procore_offices`, `procore_project_map`, and + the generic allow-listed `procore_data`. +- **A Procore block** with one inserter variation per shortcode, a settings sidebar and a + live server-rendered preview. No build step; the shipped editor script is the source. +- **An optional read-only REST proxy** at `/wp-json/procore-connect/v1/`, serving cached data + from allow-listed endpoints with configurable access. +- **WP-CLI commands**: `test`, `doctor`, `projects`, `cache-clear`, `cache-warm`, + `reset-token`. +- **Theme template overrides** via `yourtheme/procore-connect/`, replacing the 1.x advice to + edit files inside the plugin directory — which lost customisations on every update. +- **A connection diagnostic** that reports each stage separately and probes every + registered endpoint, revealing exactly which Procore tool permissions the credentials + hold. 1.x reported only "successful" or "failed". +- **A Status screen** with rate-limit headroom, circuit state, token expiry, cache + statistics and a redacted diagnostic log. +- **A generated shortcode reference** built from the same registry the shortcodes use, so + the documentation cannot drift from the code. +- **Filters** for the endpoint registry, shortcode registry, allow-listed project fields, + API and login hosts, redirect URI, request arguments, cache toggle and template lookup. + +### Changed + +- Full internationalisation. 1.x declared a text domain but contained no translation + calls at all. +- Passes Plugin Check with no errors or warnings, and PHPCS `WordPress-Extra` plus + `WordPress-Docs`. +- Plugin headers completed: `License URI`, `Requires at least`, `Requires PHP`, + `Domain Path`, `Update URI`, and real `Plugin URI` and `Author` values in place of + `https://example.com` and `Your Name`. +- Namespaced (`ProcoreConnect\`) and consistently prefixed (`procore_connect_`, `PROCORE_CONNECT_`). +- Added `uninstall.php`, which removes credentials, tokens, cache and options unless the + site opts to keep its data. +- Removed all direct filesystem writes into the plugin directory. +- Unit tests run against fixtures through an injected transport, needing no credentials + or network access. + +## [1.0.0] + +- Initial release. + +[2.0.0]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.0 +[1.0.0]: https://github.com/ibuilder/ProcoreWP/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c608c4f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to Procore Connect + +Thanks for helping. This document covers how to get set up and what the review will look +for. + +## Getting started + +```bash +git clone https://github.com/ibuilder/ProcoreWP.git +cd Procore Connect +composer install +``` + +There is no Node toolchain. The block editor script is hand-written ES5 against the +global `wp.*` runtime, deliberately, so that the file that ships is the file that was +authored — no compiled bundle without source. + +## Checks + +Run all three before opening a pull request. CI runs the same commands. + +```bash +composer run syntax # php -l across the tree +composer run lint # PHPCS: WordPress-Extra + WordPress-Docs +composer run test # PHPUnit +``` + +`composer run lint:fix` applies the auto-fixable subset. + +Both the standards check and the test suite are expected to be completely clean. If a +sniff is genuinely wrong for a given line, add a `phpcs:ignore` with a specific sniff +name and a one-line reason — never a bare ignore, and never a broad file-level disable. + +## Tests + +Tests run against JSON fixtures through an injected HTTP transport +(`Client::set_transport()`), so no Procore credentials and no network access are needed. +WordPress functions are shimmed in `tests/wp-shims.php`; add to it only what the code +under test actually calls. + +Please add a test for any behaviour change. The suite already covers the things that are +easy to regress: + +- the `Procore-Company-Id` header being present on every request +- requests going to the API host and never the login host +- `Link`-header pagination, and refusing links that point off-host +- 429 and 503 backoff +- cache hits, group purging, and the stale-cache fallback +- encryption round-trips and secret masking +- endpoint allow-list refusals +- every shortcode's attribute sanitization and output escaping + +## Adding an endpoint + +Endpoints live in one place: `src/Api/Endpoints.php`. Add an entry with its path, +version, scope, default cache lifetime, required Procore permission and the fields it can +render. Setting `'public' => true` makes it reachable from `[procore_data]` and the REST +proxy, so only do that for endpoints whose payload is safe to publish. + +Nothing outside that registry is callable. That is the boundary that keeps the generic +shortcode and the public proxy safe, so please do not route around it. + +## Adding a shortcode + +Shortcodes live in `src/Frontend/Shortcodes/Registrar.php`. Most need no new class — pick +`CollectionShortcode` or `RecordShortcode` and supply an endpoint, a template and a +column map. The admin reference screen, the block variations and the documentation all +read this registry, so a correct entry documents itself. + +## Escaping + +Templates get all cell content from `Format::cell()`, which escapes everything it +returns. Keeping that single choke point is what makes the escaping guarantee auditable — +please do not echo record values directly in a template. + +Procore error messages name accounts, projects and permissions. They go to +administrators and the diagnostic log, never to a public page. + +## Commits and pull requests + +- One logical change per pull request. +- Explain *why* in the description, not just what. +- Update `CHANGELOG.md` under an `## [Unreleased]` heading. +- If you change behaviour that `readme.txt` or the `docs/` site describes, update those + too. Both are part of the deliverable. + +## Security + +Do not open a public issue for a security problem. See [SECURITY.md](SECURITY.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md index 75fc0f4..cf22f52 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,184 @@ -# Procore Integration for WordPress - -Connect your WordPress site to the Procore construction management platform using this integration plugin. Display project information, team members, drawings, specifications, and more directly on your WordPress site using simple shortcodes. - -## Features - -- **Easy Authentication**: Connect to Procore API with your Client ID and Client Secret -- **Multi-Company Support**: Specify company ID in each shortcode or set a default -- **Project Information**: Display comprehensive project details -- **Team Members**: List project team members and their roles -- **Drawings & Specifications**: Show project drawings and specifications -- **Featured Images**: Display project images -- **Custom Data Fields**: Access any project data field using shortcodes -- **Responsive Design**: Works with any WordPress theme -- **Customizable Styles**: Easily modify CSS styles to match your theme - -## Installation - -1. Download the plugin ZIP file -2. Log in to your WordPress admin panel -3. Navigate to Plugins > Add New -4. Click "Upload Plugin" and select the ZIP file -5. Click "Install Now" and then "Activate Plugin" - -## File Structure - -The plugin is organized with a clean, modular structure: - -``` -procore-integration/ -├── index.php # Main plugin file -├── includes/ -│ ├── styles.php # Handles CSS loading -│ └── activation.php # Plugin activation hooks -├── assets/ -│ └── css/ -│ ├── procore-integration.css # Active CSS file (customizable) -│ └── procore-integration-default.css # Default CSS template -└── README.md # Documentation -``` - -## Configuration - -1. Go to Settings > Procore Integration -2. Enter your Procore API credentials: - - **Client ID**: Your Procore API client ID - - **Client Secret**: Your Procore API client secret - - **API URL**: Default is https://api.procore.com (usually doesn't need to be changed) - - **Default Company ID**: Your default Procore company ID (used when not specified in shortcodes) -3. Click "Save Changes" -4. Click "Test Connection" to verify your credentials work correctly - -## Getting Procore API Credentials - -To use this plugin, you'll need to create an application in the Procore Developer Portal: - -1. Go to [Procore Developer Portal](https://developers.procore.com/) -2. Sign in with your Procore account -3. Navigate to "My Apps" and click "New App" -4. Fill in the required information: - - **Name**: Your app name (e.g., "WordPress Integration") - - **Redirect URI**: Your site URL (e.g., https://example.com/wp-admin/options-general.php?page=procore-integration) - - **Permissions**: Select the permissions you need (at minimum: projects, users, documents) -5. Click "Create" to generate your Client ID and Client Secret -6. Copy these credentials to your WordPress plugin settings - -## Using Shortcodes - -### Project List - -``` -[procore_project_list company_id="123" limit="10" show_details="true" active_only="true" sort_by="name" sort_order="asc"] -``` - -Displays a list of all available Procore projects with their IDs and names. - -**Parameters:** -- `company_id`: The Procore company ID (optional if default set in settings) -- `limit`: Maximum number of projects to display (default: 0, shows all) -- `show_details`: Whether to show additional details like location and status (default: false) -- `active_only`: Whether to show only active projects (default: true) -- `sort_by`: Field to sort by - "name", "id", or "created_at" (default: "name") -- `sort_order`: Sort order - "asc" or "desc" (default: "asc") - -### Project Information - -``` -[procore_project id="123" company_id="123"] -``` - -Displays basic project information including name, address, start date, completion date, and status. - -**Parameters:** -- `id`: The Procore project ID (required) -- `company_id`: The Procore company ID (optional if default set in settings) - -### Team Members - -``` -[procore_team id="123" company_id="123"] -``` - -Lists all team members assigned to the project with their names, emails, and roles. - -**Parameters:** -- `id`: The Procore project ID (required) -- `company_id`: The Procore company ID (optional if default set in settings) - -### Featured Image - -``` -[procore_featured_image id="123" company_id="123" width="400" height="auto"] -``` - -Displays the project's featured image or logo. - -**Parameters:** -- `id`: The Procore project ID (required) -- `company_id`: The Procore company ID (optional if default set in settings) -- `width`: Image width in pixels (default: 300) -- `height`: Image height in pixels (default: auto) - -### Drawings - -``` -[procore_drawings id="123" company_id="123" limit="5"] -``` - -Lists project drawings with their names and descriptions. - -**Parameters:** -- `id`: The Procore project ID (required) -- `company_id`: The Procore company ID (optional if default set in settings) -- `limit`: Maximum number of drawings to display (default: 10) - -### Specifications - -``` -[procore_specifications id="123" company_id="123" limit="5"] -``` - -Lists project specifications with their numbers, titles, and descriptions. - -**Parameters:** -- `id`: The Procore project ID (required) -- `company_id`: The Procore company ID (optional if default set in settings) -- `limit`: Maximum number of specifications to display (default: 10) - -### Custom Project Data - -``` -[procore_project_data id="123" company_id="123" field="budget" label="Project Budget"] -``` - -Displays a specific project data field. - -**Parameters:** -- `id`: The Procore project ID (required) -- `company_id`: The Procore company ID (optional if default set in settings) -- `field`: The API field name to display (required) -- `label`: Custom label for the field (optional, defaults to formatted field name) - -## Example Page Layout - -Here's an example of how you might use multiple shortcodes on a single page: - -``` -

Project Overview

- -[procore_featured_image id="123" company_id="456" width="600"] - -[procore_project id="123" company_id="456"] - -

Key Information

- -
- [procore_project_data id="123" company_id="456" field="budget" label="Budget"] - [procore_project_data id="123" company_id="456" field="square_feet" label="Square Footage"] - [procore_project_data id="123" company_id="456" field="project_number" label="Project Number"] -
- -

Project Team

- -[procore_team id="123" company_id="456"] - -

Project Drawings

- -[procore_drawings id="123" company_id="456" limit="5"] - -

Project Specifications

- -[procore_specifications id="123" company_id="456" limit="5"] -``` - -And here's an example of a Projects Directory page using the project list shortcode: - -``` -

Procore Projects Directory

- -

Below is a list of all our current active projects in Procore:

- -[procore_project_list company_id="456" show_details="true" active_only="true" sort_by="name"] - -

Click on a project ID to view more details about that specific project.

-``` - -## Customizing Styles - -The plugin includes default CSS styles that you can customize to match your theme: - -1. Navigate to the plugin directory in your WordPress installation: `/wp-content/plugins/procore-integration/` -2. Edit the file at `assets/css/procore-integration.css` -3. Save your changes - -If you need to reset to the default styles, you can copy the contents from `assets/css/procore-integration-default.css` into your active CSS file. - -## Finding Your Company ID - -To locate your Procore Company ID: - -1. Log in to your Procore account -2. Look at the URL in your browser when viewing your company dashboard -3. The URL will contain a pattern like `https://app.procore.com/companies/XXXX/...` where `XXXX` is your company ID -4. Alternatively, you can use the [procore_project_list] shortcode without a company ID first, and the API error message may include information about available company IDs - -You can set a default Company ID in the plugin settings page to avoid having to specify it in every shortcode. - -## Troubleshooting - -### Common Issues - -1. **Connection Failed**: Make sure your Client ID and Client Secret are correct. Check that your Procore account has the necessary permissions. - -2. **No Data Displayed**: Ensure you're using the correct project ID and company ID in your shortcodes. Project IDs can be found in the URL when viewing a project in Procore (e.g., `https://app.procore.com/projects/123/...`). - -3. **Company ID Error**: If you're getting errors about company_id, make sure you're either specifying the correct company ID in each shortcode or have set a default company ID in the plugin settings. - -4. **Drawings or Specifications Not Showing**: Not all projects have drawings or specifications. Check that these exist in your Procore project. - -5. **API Rate Limiting**: Procore may limit the number of API requests. If you're displaying many shortcodes on a single page, consider caching the data. - -### Support - -If you encounter issues, check the following: - -1. WordPress error logs -2. Procore API documentation at [developers.procore.com](https://developers.procore.com/documentation) -3. Contact the plugin developer for support - -## Changelog - -### Version 1.0.0 -- Initial release with multi-company support -- Separated CSS into a dedicated file for easier customization -- Added modular file structure for better maintainability - -## License - -This plugin is licensed under the GPL v2 or later. - -## Credits - -Developed by [Your Name/Company] - -## Privacy - -This plugin connects to the Procore API and sends/receives data from Procore's servers. No data is shared with any third party. Please review Procore's privacy policy for information on how they handle your data. \ No newline at end of file +# Procore Connect + +[![Plugin Check](https://github.com/ibuilder/ProcoreWP/actions/workflows/plugin-check.yml/badge.svg)](https://github.com/ibuilder/ProcoreWP/actions/workflows/plugin-check.yml) +[![Coding Standards](https://github.com/ibuilder/ProcoreWP/actions/workflows/phpcs.yml/badge.svg)](https://github.com/ibuilder/ProcoreWP/actions/workflows/phpcs.yml) +[![Tests](https://github.com/ibuilder/ProcoreWP/actions/workflows/tests.yml/badge.svg)](https://github.com/ibuilder/ProcoreWP/actions/workflows/tests.yml) +[![License: GPL v2+](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE) + +Publish live [Procore](https://www.procore.com/) construction project data on a WordPress site — with shortcodes, blocks, and a cached REST proxy. + +**[Documentation](https://ibuilder.github.io/ProcoreWP/)** · [Installation](https://ibuilder.github.io/ProcoreWP/installation/) · [Authentication](https://ibuilder.github.io/ProcoreWP/authentication/) · [Shortcode reference](https://ibuilder.github.io/ProcoreWP/shortcodes/) · [Upgrading from 1.x](https://ibuilder.github.io/ProcoreWP/upgrading/) + +--- + +## What it does + +Renders Procore data on the front end of a WordPress site: project directories, project detail panels, team lists, drawings, specifications, RFIs, submittals, punch lists, observations, daily logs, change orders, schedule milestones, company vendors and offices. + +Everything is **read-only**. Procore Connect never writes to Procore. + +``` +[procore_project_list company_id="4242" limit="10"] + +[procore_project id="123"] +[procore_team id="123"] +[procore_rfis id="123" status="open" limit="5"] + +[procore_data endpoint="submittals" project_id="123" columns="number,title,status,due_date"] +``` + +## Why version 2 exists + +Version 1.x could not work against the live API. It posted its token request to `api.procore.com`, but Procore serves authentication from `login.procore.com` — a different host — so no token was ever issued. It also sent the company scope as a `company_id` query parameter where Procore requires the `Procore-Company-Id` header, stored the client secret as plaintext in an autoloaded option, ran its connection test from an unverified `$_POST`, and cached nothing at all against an API with a documented ten-second spike limit. + +Version 2.0.0 is a rewrite. The full list is in [CHANGELOG.md](CHANGELOG.md). + +## Highlights + +| | | +|---|---| +| **Two auth modes** | Client Credentials via a Developer Managed Service Account, or Authorization Code on behalf of a Procore user. Chosen in the admin. | +| **Rate-limit aware** | Reads `X-Rate-Limit-*`, honours `Retry-After`, backs off exponentially with jitter, and trips a circuit breaker after repeated failures. | +| **Caches by default** | Per-endpoint lifetimes with a site-wide floor no page can undercut. Uses the transient API, so Redis and Memcached work automatically. | +| **Degrades gracefully** | A Procore outage serves the last good response instead of blanking a published page. | +| **Real pagination** | Follows Procore's `Link: rel="next"` headers, refusing any link that points off the configured API host. | +| **Encrypted at rest** | AES-256-GCM, keyed from the site's own salts. Or keep credentials out of the database entirely with `wp-config.php` constants. | +| **Private by default** | Email addresses are suppressed unless you opt in twice. Procore error messages reach administrators only. | +| **Diagnostics** | The connection test reports every stage and probes each endpoint, so you can see which Procore tool permissions you actually have. | + +## Requirements + +WordPress 6.5+ · PHP 7.4+ · A Procore account with API access + +## Quick start + +1. Install and activate the plugin. +2. **Procore → Connection** — enter your Client ID and Client Secret. +3. Click **Look up companies** and pick a default company. +4. Click **Test connection**. Each stage reports separately, and the endpoint probe shows which shortcodes your permissions support. +5. Add a shortcode or the **Procore** block to a page. + +Getting credentials is covered step by step in [the authentication guide](https://ibuilder.github.io/ProcoreWP/authentication/). + +### Keeping the secret out of the database + +```php +// wp-config.php +define( 'PROCORE_CONNECT_CLIENT_ID', 'your-client-id' ); +define( 'PROCORE_CONNECT_CLIENT_SECRET', 'your-client-secret' ); +define( 'PROCORE_CONNECT_COMPANY_ID', 4242 ); +``` + +These take precedence over the admin fields and are never written to an option. + +## Shortcodes + +| Shortcode | Shows | Procore permission | +|---|---|---| +| `[procore_project_list]` | Projects in a company | Company Admin / Project Directory | +| `[procore_project]` | One project's details | Project Admin | +| `[procore_project_data]` | One allow-listed project field | Project Admin | +| `[procore_featured_image]` | Project logo or photo | Project Admin | +| `[procore_team]` | Project directory users | Project Directory | +| `[procore_drawings]` | Drawing areas | Drawings | +| `[procore_specifications]` | Specification sections | Specifications | +| `[procore_rfis]` | RFIs | RFIs | +| `[procore_submittals]` | Submittals | Submittals | +| `[procore_punch_list]` | Punch items | Punch List | +| `[procore_observations]` | Observations | Observations | +| `[procore_daily_logs]` | Daily construction reports | Daily Log | +| `[procore_change_orders]` | Change order packages | Change Orders | +| `[procore_milestones]` | Schedule tasks | Schedule | +| `[procore_vendors]` | Company directory | Company Directory | +| `[procore_offices]` | Company offices | Company Admin | +| `[procore_project_map]` | Project locations with geo microdata | Company Admin | +| `[procore_data]` | Any published endpoint | Depends on `endpoint` | + +Shared attributes: `company_id`, `project_id`, `limit`, `page`, `orderby`, `order`, `columns`, `template`, `class`, `title`, `cache`, `empty_text`, `show_email`, `all`. + +The legacy `id` attribute from 1.x is still accepted as an alias for `project_id`, so existing pages keep working. A live, always-accurate reference is generated inside the plugin at **Procore → Shortcodes**. + +## Blocks + +A single **Procore** block registers one inserter variation per shortcode, with a settings sidebar and a live server-rendered preview. It delegates to the same renderer the shortcodes use, so markup and escaping are identical in both surfaces. + +The editor script is hand-written ES5 against the global `wp.*` runtime. There is no build step and no `node_modules` — the file that ships is the file that was authored. + +## REST proxy + +Optional, off by default. Enable it under **Procore → Tools**. + +``` +GET /wp-json/procore-connect/v1/endpoints +GET /wp-json/procore-connect/v1/data/rfis?project_id=123&per_page=25 +``` + +Read-only, served from cache, restricted to endpoints published in the registry, with access configurable between public, logged-in and editor. Credentials never reach the browser. + +## WP-CLI + +```bash +wp procore-connect test +wp procore-connect doctor +wp procore-connect projects --format=csv +wp procore-connect cache-clear --group=rfis +wp procore-connect cache-warm +wp procore-connect reset-token +``` + +## Customising output + +Copy any file from `templates/` into `yourtheme/procore-connect/` and edit it there. Overrides survive plugin updates — unlike 1.x, which told you to edit CSS inside the plugin directory and lost your work on every release. + +``` +wp-content/plugins/procore-connect/templates/collection.php + → wp-content/themes/your-theme/procore-connect/collection.php +``` + +Templates: `collection`, `record`, `field`, `image`, `map`. For styling only, use **Procore → Display → Custom CSS**. + +### Filters + +| Filter | Purpose | +|---|---| +| `procore_connect_endpoints` | Correct or add an endpoint after a Procore resource version bump | +| `procore_connect_shortcodes` | Register a shortcode, or change a column map | +| `procore_connect_allowed_project_fields` | Extend what `[procore_project_data]` may display | +| `procore_connect_api_host` / `procore_connect_login_host` | Point at a regional or federal zone | +| `procore_connect_redirect_uri` | Override the OAuth redirect URI | +| `procore_connect_request_args` | Adjust the HTTP arguments of an API request | +| `procore_connect_cache_enabled` | Disable caching programmatically | +| `procore_connect_template_candidates` | Change where templates are looked up | + +## Development + +```bash +composer install +composer run lint # PHPCS: WordPress-Extra + WordPress-Docs +composer run test # PHPUnit +composer run syntax # php -l across the tree +``` + +Tests run against JSON fixtures through an injected HTTP transport, so no Procore credentials or network access are needed. They cover token handling, the refresh mutex, 429/503 backoff, `Link` pagination, cache keys and purging, the stale fallback, encryption round-trips, endpoint allow-list refusals, and every shortcode's attribute sanitization and output escaping. + +Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: [SECURITY.md](SECURITY.md). + +## Upgrading from 1.x + +Settings are imported automatically, all shortcode names are unchanged, and the legacy `id` attribute still works. + +Two things need your attention. The main plugin file was renamed from `index.php` to `procore-connect.php`, which WordPress sees as a different plugin, so **activate Procore Connect once after updating**. And because 1.x never successfully authenticated, **re-run the connection test** before assuming a blank shortcode is a bug. + +Full detail: [the upgrade guide](https://ibuilder.github.io/ProcoreWP/upgrading/). + +## Third-party service + +Procore Connect contacts Procore to retrieve the data you ask it to display: `login.procore.com` for authentication and `api.procore.com` for data, or the sandbox or regional hosts you configure. It sends your Client ID and Client Secret during authentication, plus the company and project identifiers you configure. No visitor data is sent to Procore. + +[Procore terms](https://www.procore.com/legal/termsofservice) · [Procore privacy policy](https://www.procore.com/legal/privacy) · [Procore API docs](https://developers.procore.com/documentation/introduction) + +## License + +GPL-2.0-or-later. See [LICENSE](LICENSE). + +Procore Connect is not affiliated with, endorsed by, or sponsored by Procore Technologies, Inc. "Procore" is a trademark of Procore Technologies, Inc. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..806361b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| 2.0.x | ✅ | +| 1.x | ❌ — see the note below | + +Version 1.x is not supported and should not be used. It stored the Procore client secret +and access token as plaintext in an autoloaded WordPress option, ran its connection test +from an unverified `$_POST` key, and registered its settings with no sanitize callback. +It also could not authenticate against the live API. Upgrade to 2.0.0 and rotate any +credentials that were stored by 1.x, on the assumption they were exposed. + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue. + +Use GitHub's private reporting instead: +[Report a vulnerability](https://github.com/ibuilder/ProcoreWP/security/advisories/new) + +Helpful things to include: affected version, a description of the impact, reproduction +steps, and any proof-of-concept. Please do not include real Procore credentials — redact +them. + +You can expect an acknowledgement within a few days and a fix or a plan within 30 days +for confirmed issues. Credit is given in the changelog unless you prefer otherwise. + +## How credentials are handled + +- The client secret, access token and refresh token are encrypted with AES-256-GCM before + storage, keyed from the site's own WordPress salts, so a database dump without + `wp-config.php` does not disclose them. +- They are stored in a non-autoloaded option and never rendered into a form field; the + admin shows a mask. +- Sites may instead define `PROCORE_CONNECT_CLIENT_ID` and `PROCORE_CONNECT_CLIENT_SECRET` in + `wp-config.php`. These take precedence and never reach the database. This is the + recommended production setup. +- If OpenSSL is unavailable the plugin warns in the admin and recommends the constants. + +## Boundaries worth knowing about + +- Every Procore path the plugin can reach is declared in `src/Api/Endpoints.php`. The + generic `[procore_data]` shortcode and the REST proxy resolve against that registry + and cannot reach anything outside it. +- The REST proxy is off by default, is read-only, and never exposes credentials. +- `[procore_project_data]` reads from a field allow-list, not the whole payload. +- Email addresses are suppressed by default and require two independent opt-ins. +- Procore error messages are shown to users with `manage_options` only. +- `Link` headers are followed only when they point at the configured API host. + +## Scope + +In scope: this plugin's code. Out of scope: vulnerabilities in WordPress core, in +third-party plugins or themes, or in the Procore API itself — report those to their +respective maintainers. diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..77cb49e --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,77 @@ +/*! + * Procore Connect admin styles. + */ + +.procore-connect-admin .nav-tab-wrapper { + margin-bottom: 1.25rem; +} + +.procore-connect-admin code.procore-connect-copy { + cursor: pointer; + position: relative; +} + +.procore-connect-admin code.procore-connect-copy.is-copied::after { + content: attr(data-copied-label, "Copied"); + position: absolute; + inset-inline-start: 100%; + margin-inline-start: 0.5rem; + white-space: nowrap; + color: #1d7a3f; + font-weight: 600; +} + +.procore-connect-steps { + margin: 0 0 1.5rem; + padding: 0; + list-style: none; + max-width: 60rem; +} + +.procore-connect-step { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + padding: 0.625rem 0.875rem; + border-left: 4px solid #dcdcde; + background: #fff; + margin-bottom: 0.375rem; +} + +.procore-connect-step strong { + min-width: 12rem; +} + +.procore-connect-step--ok { + border-left-color: #1d7a3f; +} + +.procore-connect-step--fail { + border-left-color: #b32d2e; +} + +.procore-connect-step__message { + flex: 1 1 20rem; + color: #50575e; +} + +.procore-connect-probes { + max-width: 60rem; +} + +.procore-connect-probe--ok { + color: #1d7a3f; +} + +.procore-connect-probe--failed { + color: #b32d2e; +} + +.procore-connect-probe--skipped { + color: #7c7c7c; + font-style: italic; +} + +#procore-connect-company-list { + margin-top: 0.5rem; +} diff --git a/assets/css/procore-connect.css b/assets/css/procore-connect.css new file mode 100644 index 0000000..6f2b5df --- /dev/null +++ b/assets/css/procore-connect.css @@ -0,0 +1,229 @@ +/*! + * Procore Connect front-end styles. + * + * Do not edit this file: it is replaced on every plugin update. To restyle the + * output, either add rules under Procore -> Display -> Custom CSS, or copy a + * template from the plugin's templates/ directory into yourtheme/procore-connect/. + */ + +.procore-connect { + margin: 0 0 1.5rem; +} + +.procore-connect-title { + margin: 0 0 0.75rem; + font-size: 1.125rem; + line-height: 1.3; +} + +/* Tables --------------------------------------------------------------- */ + +.procore-connect-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.procore-connect-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; +} + +.procore-connect-table th, +.procore-connect-table td { + padding: 0.625rem 0.75rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +.procore-connect-table thead th { + font-weight: 600; + white-space: nowrap; + border-bottom-width: 2px; +} + +.procore-connect-table tbody tr:last-child td { + border-bottom: 0; +} + +/* Detail lists --------------------------------------------------------- */ + +.procore-connect-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem 1.5rem; + margin: 0; +} + +.procore-connect-detail dt { + margin: 0 0 0.125rem; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; +} + +.procore-connect-detail dd { + margin: 0; +} + +/* Single fields -------------------------------------------------------- */ + +.procore-connect-field { + display: inline-flex; + gap: 0.375rem; + align-items: baseline; + margin: 0 0 0.5rem; +} + +.procore-connect-field__label { + font-weight: 600; +} + +.procore-connect-field__label::after { + content: ":"; +} + +/* Status badges -------------------------------------------------------- */ + +.procore-connect-status { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.8125rem; + line-height: 1.5; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); +} + +.procore-connect-status--active { + background: rgba(22, 128, 70, 0.12); + color: #0f5132; +} + +.procore-connect-status--inactive { + background: rgba(0, 0, 0, 0.07); + opacity: 0.8; +} + +/* Images --------------------------------------------------------------- */ + +.procore-connect-image { + margin: 0 0 1.5rem; +} + +.procore-connect-image img { + max-width: 100%; + height: auto; + display: block; +} + +/* Locations ------------------------------------------------------------ */ + +.procore-connect-map__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; +} + +.procore-connect-map__item { + padding: 0.875rem 1rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0.375rem; +} + +.procore-connect-map__name { + display: block; + font-weight: 600; +} + +.procore-connect-map__location { + display: block; + font-size: 0.875rem; + opacity: 0.75; +} + +.procore-connect-map__link { + display: inline-block; + margin-top: 0.375rem; + font-size: 0.875rem; +} + +/* Notices -------------------------------------------------------------- */ + +.procore-connect-notice { + padding: 0.75rem 1rem; + margin: 0 0 1.5rem; + border-left: 3px solid rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.03); + font-size: 0.9375rem; +} + +.procore-connect-notice--error { + border-left-color: #b32d2e; + background: rgba(179, 45, 46, 0.06); +} + +.procore-connect-notice--empty { + font-style: italic; + opacity: 0.85; +} + +/* Small screens -------------------------------------------------------- */ + +@media (max-width: 600px) { + .procore-connect-table, + .procore-connect-table thead, + .procore-connect-table tbody, + .procore-connect-table tr, + .procore-connect-table td { + display: block; + width: 100%; + } + + .procore-connect-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + .procore-connect-table tr { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + .procore-connect-table td { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0; + border: 0; + } + + .procore-connect-table td::before { + content: attr(data-label); + font-weight: 600; + opacity: 0.7; + } +} + +@media (prefers-color-scheme: dark) { + .procore-connect-table th, + .procore-connect-table td, + .procore-connect-map__item { + border-color: rgba(255, 255, 255, 0.15); + } + + .procore-connect-status, + .procore-connect-notice { + background: rgba(255, 255, 255, 0.07); + } +} diff --git a/assets/css/procore-integration.css b/assets/css/procore-integration.css deleted file mode 100644 index 1bec953..0000000 --- a/assets/css/procore-integration.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Procore Integration Styles */ -.procore-project { - margin-bottom: 30px; -} -.procore-project-details { - margin-top: 15px; -} -.procore-team-list, -.procore-drawings-list, -.procore-specifications-list { - list-style: none; - padding: 0; - margin: 0; -} -.procore-team-member, -.procore-drawing, -.procore-specification { - margin-bottom: 15px; - padding: 15px; - background: #f9f9f9; - border-radius: 4px; -} -.procore-member-name, -.procore-drawing-name, -.procore-spec-title { - font-weight: bold; - margin-bottom: 5px; -} -.procore-project-image img { - max-width: 100%; - height: auto; -} -.procore-project-data { - margin: 10px 0; -} -.procore-data-label { - font-weight: bold; -} -.procore-projects-table { - width: 100%; - border-collapse: collapse; - margin: 15px 0; -} -.procore-projects-table th, -.procore-projects-table td { - padding: 8px 12px; - text-align: left; - border-bottom: 1px solid #ddd; -} -.procore-projects-table th { - background-color: #f2f2f2; - font-weight: bold; -} -.procore-projects-table tr:hover { - background-color: #f5f5f5; -} -.procore-project-id { - font-family: monospace; - white-space: nowrap; -} -.error { - color: #d63638; - font-weight: bold; -} \ No newline at end of file diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..8a7edaf --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,326 @@ +/** + * Procore Connect admin behaviour. + * + * Plain ES5 with no build step, so the shipped file is the source file. + * + * @package ProcoreConnect + */ + +( function () { + 'use strict'; + + var config = window.procoreConnectAdmin || {}; + var strings = config.strings || {}; + + /** + * POST to admin-ajax with the shared nonce. + * + * @param {string} action Ajax action name. + * @param {Object} data Additional payload. + * @param {Function} onDone Success callback. + * @param {Function} onFail Failure callback. + */ + function post( action, data, onDone, onFail ) { + var body = new window.FormData(); + + body.append( 'action', action ); + body.append( 'nonce', config.nonce ); + + Object.keys( data || {} ).forEach( function ( key ) { + body.append( key, data[ key ] ); + } ); + + window + .fetch( config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + } ) + .then( function ( response ) { + return response.json(); + } ) + .then( function ( payload ) { + if ( payload && payload.success ) { + onDone( payload.data || {} ); + } else { + onFail( ( payload && payload.data && payload.data.message ) || strings.failed ); + } + } ) + .catch( function () { + onFail( strings.failed ); + } ); + } + + /** + * Create an element with text content. + * + * @param {string} tag Tag name. + * @param {string} className Class attribute. + * @param {string} text Text content. + * @return {HTMLElement} The element. + */ + function el( tag, className, text ) { + var node = document.createElement( tag ); + + if ( className ) { + node.className = className; + } + + if ( undefined !== text && null !== text ) { + node.textContent = text; + } + + return node; + } + + /** + * Render the connection diagnostic report. + * + * @param {Object} report Report payload. + */ + function renderReport( report ) { + var target = document.getElementById( 'procore-connect-test-results' ); + + if ( ! target ) { + return; + } + + target.innerHTML = ''; + + var summary = el( + 'div', + 'notice notice-' + ( report.ok ? 'success' : 'warning' ) + ' procore-connect-summary' + ); + summary.appendChild( el( 'p', '', report.summary ) ); + target.appendChild( summary ); + + var list = el( 'ul', 'procore-connect-steps' ); + + ( report.steps || [] ).forEach( function ( step ) { + var item = el( 'li', 'procore-connect-step procore-connect-step--' + ( step.ok ? 'ok' : 'fail' ) ); + + item.appendChild( el( 'strong', '', step.label ) ); + item.appendChild( el( 'span', 'procore-connect-step__message', step.message ) ); + list.appendChild( item ); + } ); + + target.appendChild( list ); + + if ( ! report.probes || ! report.probes.length ) { + return; + } + + target.appendChild( el( 'h3', '', 'Endpoint permissions' ) ); + + var table = el( 'table', 'widefat striped procore-connect-probes' ); + var tbody = document.createElement( 'tbody' ); + + report.probes.forEach( function ( probe ) { + var row = document.createElement( 'tr' ); + + row.appendChild( el( 'td', '', probe.label ) ); + row.appendChild( + el( 'td', 'procore-connect-probe--' + probe.status, probe.message ) + ); + row.appendChild( el( 'td', '', probe.permission ) ); + tbody.appendChild( row ); + } ); + + table.appendChild( tbody ); + target.appendChild( table ); + } + + /** + * Wire the connection test button. + */ + function bindTest() { + var button = document.getElementById( 'procore-connect-test' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + var original = button.textContent; + + button.disabled = true; + button.textContent = strings.testing; + + post( + 'procore_connect_test_connection', + {}, + function ( report ) { + button.disabled = false; + button.textContent = original; + renderReport( report ); + }, + function ( message ) { + button.disabled = false; + button.textContent = original; + renderReport( { ok: false, summary: message, steps: [], probes: [] } ); + } + ); + } ); + } + + /** + * Wire the cache purge button. + */ + function bindCache() { + var button = document.getElementById( 'procore-connect-clear-cache' ); + var message = document.getElementById( 'procore-connect-cache-message' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + button.disabled = true; + + if ( message ) { + message.textContent = strings.clearing; + } + + post( + 'procore_connect_clear_cache', + {}, + function ( data ) { + button.disabled = false; + + if ( message ) { + message.textContent = data.message || ''; + } + }, + function ( text ) { + button.disabled = false; + + if ( message ) { + message.textContent = text; + } + } + ); + } ); + } + + /** + * Wire the company lookup button. + */ + function bindCompanies() { + var button = document.getElementById( 'procore-connect-load-companies' ); + var list = document.getElementById( 'procore-connect-company-list' ); + var input = document.getElementById( 'procore-connect-company-id' ); + + if ( ! button || ! list ) { + return; + } + + button.addEventListener( 'click', function () { + button.disabled = true; + list.textContent = strings.loading; + + post( + 'procore_connect_companies', + {}, + function ( data ) { + button.disabled = false; + list.innerHTML = ''; + + var select = document.createElement( 'select' ); + + ( data.companies || [] ).forEach( function ( company ) { + var option = document.createElement( 'option' ); + + option.value = company.id; + option.textContent = company.name + ' (' + company.id + ')'; + + if ( input && String( company.id ) === String( input.value ) ) { + option.selected = true; + } + + select.appendChild( option ); + } ); + + select.addEventListener( 'change', function () { + if ( input ) { + input.value = select.value; + } + } ); + + list.appendChild( select ); + }, + function ( text ) { + button.disabled = false; + list.textContent = text; + } + ); + } ); + } + + /** + * Wire the disconnect button. + */ + function bindDisconnect() { + var button = document.getElementById( 'procore-connect-disconnect' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + if ( ! window.confirm( strings.confirmed ) ) { + return; + } + + button.disabled = true; + + post( + 'procore_connect_disconnect', + {}, + function () { + window.location.reload(); + }, + function () { + button.disabled = false; + } + ); + } ); + } + + /** + * Wire click-to-copy on code elements. + */ + function bindCopy() { + document.addEventListener( 'click', function ( event ) { + var trigger = event.target.closest( '[data-procore-connect-copy], [data-procore-connect-copy-button]' ); + + if ( ! trigger ) { + return; + } + + var source = trigger.hasAttribute( 'data-procore-connect-copy' ) + ? trigger + : trigger.parentNode.querySelector( '[data-procore-connect-copy]' ); + + if ( ! source || ! window.navigator.clipboard ) { + return; + } + + window.navigator.clipboard + .writeText( source.getAttribute( 'data-procore-connect-copy' ) ) + .then( function () { + source.classList.add( 'is-copied' ); + window.setTimeout( function () { + source.classList.remove( 'is-copied' ); + }, 1500 ); + } ); + } ); + } + + document.addEventListener( 'DOMContentLoaded', function () { + bindTest(); + bindCache(); + bindCompanies(); + bindDisconnect(); + bindCopy(); + } ); +} )(); diff --git a/blocks/procore/block.json b/blocks/procore/block.json new file mode 100644 index 0000000..7b4b205 --- /dev/null +++ b/blocks/procore/block.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "procore-connect/procore", + "title": "Procore", + "category": "widgets", + "icon": "building", + "description": "Display data from Procore. Pick what to show, then set the project or company it applies to.", + "keywords": [ "procore", "construction", "project", "rfi", "submittal" ], + "textdomain": "procore-connect", + "supports": { + "html": false, + "align": [ "wide", "full" ], + "spacing": { + "margin": true, + "padding": true + }, + "color": { + "background": true, + "text": true + } + }, + "attributes": { + "shortcode": { + "type": "string", + "default": "procore_project_list" + }, + "atts": { + "type": "object", + "default": {} + } + }, + "editorScript": "file:./index.js", + "style": "procore-connect" +} diff --git a/blocks/procore/index.js b/blocks/procore/index.js new file mode 100644 index 0000000..9716885 --- /dev/null +++ b/blocks/procore/index.js @@ -0,0 +1,219 @@ +/** + * Procore Connect block editor integration. + * + * Hand-written ES5 against the global `wp.*` runtime, with no build step, so + * the file that ships is the file that was authored. + * + * @package ProcoreConnect + */ + +( function ( wp, config ) { + 'use strict'; + + if ( ! wp || ! wp.blocks || ! wp.element ) { + return; + } + + var el = wp.element.createElement; + var Fragment = wp.element.Fragment; + var __ = wp.i18n.__; + var InspectorControls = wp.blockEditor.InspectorControls; + var useBlockProps = wp.blockEditor.useBlockProps; + var PanelBody = wp.components.PanelBody; + var SelectControl = wp.components.SelectControl; + var TextControl = wp.components.TextControl; + var ToggleControl = wp.components.ToggleControl; + var Placeholder = wp.components.Placeholder; + var ServerSideRender = wp.serverSideRender; + + var variations = ( config && config.variations ) || []; + + /** + * Attributes surfaced as controls, in display order. + * + * Everything else remains reachable through the equivalent shortcode. + */ + var CONTROLS = [ + { key: 'project_id', label: __( 'Project ID', 'procore-connect' ), type: 'number' }, + { key: 'company_id', label: __( 'Company ID', 'procore-connect' ), type: 'number' }, + { key: 'endpoint', label: __( 'Endpoint', 'procore-connect' ), type: 'text' }, + { key: 'field', label: __( 'Field', 'procore-connect' ), type: 'text' }, + { key: 'label', label: __( 'Label', 'procore-connect' ), type: 'text' }, + { key: 'title', label: __( 'Heading', 'procore-connect' ), type: 'text' }, + { key: 'limit', label: __( 'Maximum rows', 'procore-connect' ), type: 'number' }, + { key: 'status', label: __( 'Filter by status', 'procore-connect' ), type: 'text' }, + { key: 'orderby', label: __( 'Sort by field', 'procore-connect' ), type: 'text' }, + { key: 'order', label: __( 'Sort direction', 'procore-connect' ), type: 'select', options: [ + { label: __( 'Ascending', 'procore-connect' ), value: 'asc' }, + { label: __( 'Descending', 'procore-connect' ), value: 'desc' }, + ] }, + { key: 'columns', label: __( 'Columns', 'procore-connect' ), type: 'text' }, + { key: 'width', label: __( 'Image width', 'procore-connect' ), type: 'number' }, + { key: 'show_email', label: __( 'Show email addresses', 'procore-connect' ), type: 'toggle' }, + { key: 'all', label: __( 'Fetch every page', 'procore-connect' ), type: 'toggle' }, + { key: 'class', label: __( 'Extra CSS classes', 'procore-connect' ), type: 'text' }, + ]; + + /** + * Find the variation definition for a shortcode tag. + * + * @param {string} shortcode Shortcode tag. + * @return {Object|null} Variation, or null. + */ + function findVariation( shortcode ) { + for ( var i = 0; i < variations.length; i++ ) { + if ( variations[ i ].shortcode === shortcode ) { + return variations[ i ]; + } + } + + return null; + } + + /** + * Build the inspector controls for the selected shortcode. + * + * @param {Object} attributes Block attributes. + * @param {Function} setAttributes Attribute setter. + * @return {Array} Control elements. + */ + function buildControls( attributes, setAttributes ) { + var variation = findVariation( attributes.shortcode ); + var supported = variation ? variation.atts : []; + var atts = attributes.atts || {}; + + /** + * Persist one attribute. + * + * @param {string} key Attribute name. + * @param {*} value New value. + */ + function update( key, value ) { + var next = Object.assign( {}, atts ); + + if ( '' === value || false === value || null === value || undefined === value ) { + delete next[ key ]; + } else { + next[ key ] = String( value ); + } + + setAttributes( { atts: next } ); + } + + return CONTROLS.filter( function ( control ) { + return -1 !== supported.indexOf( control.key ); + } ).map( function ( control ) { + var value = undefined === atts[ control.key ] ? '' : atts[ control.key ]; + + if ( 'toggle' === control.type ) { + return el( ToggleControl, { + key: control.key, + label: control.label, + checked: 'true' === value, + onChange: function ( next ) { + update( control.key, next ? 'true' : '' ); + }, + __nextHasNoMarginBottom: true, + } ); + } + + if ( 'select' === control.type ) { + return el( SelectControl, { + key: control.key, + label: control.label, + value: value, + options: [ { label: __( 'Default', 'procore-connect' ), value: '' } ].concat( control.options ), + onChange: function ( next ) { + update( control.key, next ); + }, + __nextHasNoMarginBottom: true, + } ); + } + + return el( TextControl, { + key: control.key, + label: control.label, + type: 'number' === control.type ? 'number' : 'text', + value: value, + onChange: function ( next ) { + update( control.key, next ); + }, + __nextHasNoMarginBottom: true, + } ); + } ); + } + + wp.blocks.registerBlockType( 'procore-connect/procore', { + edit: function ( props ) { + var attributes = props.attributes; + var setAttributes = props.setAttributes; + var blockProps = useBlockProps ? useBlockProps() : {}; + var variation = findVariation( attributes.shortcode ); + + var inspector = el( + InspectorControls, + {}, + el( + PanelBody, + { title: __( 'Procore data', 'procore-connect' ), initialOpen: true }, + el( SelectControl, { + label: __( 'Show', 'procore-connect' ), + value: attributes.shortcode, + options: variations.map( function ( item ) { + return { label: item.title, value: item.shortcode }; + } ), + onChange: function ( next ) { + setAttributes( { shortcode: next } ); + }, + help: variation ? variation.description : '', + __nextHasNoMarginBottom: true, + } ) + ), + el( + PanelBody, + { title: __( 'Options', 'procore-connect' ), initialOpen: true }, + buildControls( attributes, setAttributes ) + ) + ); + + var preview = ServerSideRender + ? el( ServerSideRender, { + block: 'procore-connect/procore', + attributes: attributes, + EmptyResponsePlaceholder: function () { + return el( + Placeholder, + { icon: 'building', label: __( 'Procore', 'procore-connect' ) }, + __( 'Nothing to display yet. Check the project and company IDs in the block settings.', 'procore-connect' ) + ); + }, + } ) + : el( + Placeholder, + { icon: 'building', label: __( 'Procore', 'procore-connect' ) }, + variation ? variation.title : attributes.shortcode + ); + + return el( Fragment, {}, inspector, el( 'div', blockProps, preview ) ); + }, + + save: function () { + // Rendered server-side so cached data and escaping stay on the server. + return null; + }, + + variations: variations.map( function ( item ) { + return { + name: item.name, + title: item.title, + description: item.description, + icon: 'building', + attributes: { shortcode: item.shortcode }, + scope: [ 'inserter' ], + isActive: function ( blockAttributes ) { + return blockAttributes.shortcode === item.shortcode; + }, + }; + } ), + } ); +} )( window.wp, window.procoreConnectBlocks ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8eb3d7a --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "ibuilder/procore-connect", + "description": "Connect WordPress to the Procore construction management platform and display project data with shortcodes, blocks and a REST proxy.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "plugin", + "procore", + "construction", + "api" + ], + "homepage": "https://github.com/ibuilder/ProcoreWP", + "support": { + "issues": "https://github.com/ibuilder/ProcoreWP/issues", + "source": "https://github.com/ibuilder/ProcoreWP" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.9", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpunit/phpunit": "^9.6" + }, + "autoload": { + "psr-4": { + "ProcoreConnect\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ProcoreConnect\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + }, + "scripts": { + "lint": "phpcs", + "lint:fix": "phpcbf", + "test": "phpunit", + "syntax": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l" + } +} diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..bdb70f3 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,15 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3" + +group :jekyll_plugins do + gem "jekyll-remote-theme", "~> 0.4" + gem "jekyll-seo-tag", "~> 2.8" + gem "jekyll-sitemap", "~> 1.4" +end + +# Required on Ruby 3.x, where these left the standard library. +gem "webrick", "~> 1.8" +gem "csv", "~> 3.3" +gem "base64", "~> 0.2" +gem "bigdecimal", "~> 3.1" diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..7403ee7 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,38 @@ +title: Procore Connect +description: Publish live Procore construction project data on a WordPress site. +remote_theme: just-the-docs/just-the-docs + +url: https://ibuilder.github.io +baseurl: /ProcoreWP + +permalink: pretty + +plugins: + - jekyll-remote-theme + - jekyll-seo-tag + - jekyll-sitemap + +color_scheme: light +search_enabled: true +heading_anchors: true + +aux_links: + "GitHub": https://github.com/ibuilder/ProcoreWP +aux_links_new_tab: true + +nav_external_links: + - title: Procore API docs + url: https://developers.procore.com/documentation/introduction + hide_icon: false + +footer_content: >- + Procore Connect is licensed GPL-2.0-or-later. It is not affiliated with, endorsed by, or + sponsored by Procore Technologies, Inc. + +back_to_top: true +back_to_top_text: "Back to top" + +exclude: + - Gemfile + - Gemfile.lock + - vendor diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..c992bd2 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,173 @@ +--- +title: Authentication +layout: default +nav_order: 3 +--- + +# Authentication + +Procore Connect supports both of Procore's OAuth 2.0 grants. Which one you want depends on +whether the connection should belong to your organisation or to a person. + +## Which mode? + +| | Service Account (Client Credentials) | User Account (Authorization Code) | +|---|---|---| +| Acts as | A Developer Managed Service Account | A signed-in Procore user | +| Permissions | Declared in your app manifest, applied at install | Whatever that user can see | +| Survives staff changes | Yes | **No** — breaks if their access is revoked | +| Needs a redirect URI | No | Yes | +| Refresh token | None issued; a new token is simply requested | Issued, and rotated on every use | +| Best for | Almost every website | Cases needing one person's exact scope | + +**Recommendation: use the service account.** A public marketing or project site should +not depend on one employee's Procore login remaining active. + +--- + +## Service Account (Client Credentials) + +This is Procore's Developer Managed Service Account, or DMSA. The service account user is +created automatically inside the Procore company directory when a company administrator +installs your app. + +### 1. Create the app + +1. Sign in to the [Procore Developer Portal](https://developers.procore.com/). +2. Go to **My Apps** and create a new app. +3. Add an **OAuth** component and set the grant type to `client_credentials`. + +### 2. Declare permissions + +Use the Permissions Builder to declare every tool your shortcodes will read. If you skip +one, the endpoint returns 403 at runtime and the shortcode renders nothing. + +| If you use | Declare | +|---|---| +| `[procore_project_list]`, `[procore_project]` | Company Admin / Project Admin — Read Only | +| `[procore_team]` | Project Directory — Read Only | +| `[procore_vendors]` | Company Directory — Read Only | +| `[procore_offices]` | Company Admin — Read Only | +| `[procore_drawings]` | Drawings — Read Only | +| `[procore_specifications]` | Specifications — Read Only | +| `[procore_rfis]` | RFIs — Read Only | +| `[procore_submittals]` | Submittals — Read Only | +| `[procore_punch_list]` | Punch List — Read Only | +| `[procore_observations]` | Observations — Read Only | +| `[procore_daily_logs]` | Daily Log — Read Only | +| `[procore_change_orders]` | Change Orders — Read Only | +| `[procore_milestones]` | Schedule — Read Only | + +Request **read only**, and no more than you need. Procore specifically advises against +granting the service account admin access to the company Directory tool, because that +level of access permits changes across every project in the account. + +### 3. Install it + +A Procore company administrator installs the app — from the App Marketplace, or as a +custom install. During installation they choose which projects the app may access. The +service account user is provisioned automatically and the declared permissions are +applied. + +Permitted projects can be changed later under **App Management → Permissions** in +Procore. Avoid adjusting the service account's permissions by hand in the Directory tool; +that creates inconsistencies with the manifest. + +### 4. Connect WordPress + +Paste the DMSA Client ID and Client Secret into **Procore → Connection**, pick a default +company, and click **Test connection**. + +Tokens are valid for 90 minutes. No refresh token is issued for this grant — Procore Connect +simply requests a new one when the old one nears expiry, with a two-minute safety margin. + +--- + +## User Account (Authorization Code) + +### 1. Create the app + +In the Developer Portal, create an app and register the **redirect URI** exactly as shown +on **Procore → Connection** in WordPress. There is a copy button next to it. It looks +like: + +``` +https://example.com/wp-admin/admin-post.php?action=procore_connect_oauth_callback +``` + +It must match character for character, including the scheme and any `www`. + +### 2. Connect + +1. Set the authentication mode to **User Account** and save. +2. Enter the Client ID and Client Secret and save. +3. Click **Connect to Procore**, authorise the app, and you will be returned to WordPress. + +### How the round trip is protected + +Procore Connect generates a single-use `state` value, stores it in a short-lived transient, and +refuses any callback whose `state` does not match. The outbound leg additionally requires +a WordPress nonce and the `manage_options` capability. This is what stops someone +grafting their own Procore account onto your site by feeding you a crafted callback URL. + +### Refresh tokens rotate + +Procore invalidates a refresh token the moment it is exchanged and issues a new one. +Two concurrent requests both refreshing would leave one holding a dead token and lock the +site out of the API entirely. + +Procore Connect guards every refresh with a lock. A request that loses the race waits briefly +and re-reads the token the winner stored, rather than refreshing again. If a refresh token +is ever rejected outright, the connection is cleared and the admin is told to reconnect — +there is no recovery from a rotated-away refresh token. + +--- + +## Where credentials are stored + +| | | +|---|---| +| At rest | AES-256-GCM, keyed from the site's WordPress salts | +| Option | Non-autoloaded, so it is not read on every page request | +| In the admin | Masked — the real secret is never rendered into a form field | +| Best practice | `PROCORE_CONNECT_CLIENT_SECRET` in `wp-config.php`, never in the database | + +A blank Client Secret field on save means "leave the stored value alone". To remove a +stored secret, tick **Delete the stored secret**. + +If OpenSSL is unavailable, Procore Connect says so in an admin notice and falls back to +unencrypted storage — use the `wp-config.php` constants on such a host. + +--- + +## Finding your company ID + +Click **Look up companies** on the Connection screen and pick from the list. Failing that, +it is in your Procore URL: + +``` +https://app.procore.com/companies/4242/... + ^^^^ +``` + +Project IDs appear the same way: `https://app.procore.com/projects/123/...` + +--- + +## Verifying it works + +**Procore → Connection → Test connection** runs through: + +1. **Configuration** — credentials present, encryption available, which hosts are in use +2. **Access token** — obtained, via which grant, and how long it remains valid +3. **Authenticated account** — who Procore thinks you are +4. **Company access** — which companies the credentials can reach +5. **Default company** — whether projects are readable, and how many +6. **Rate limit** — remaining headroom and when the window resets + +Then it probes every registered endpoint and reports each as readable, failed or skipped, +alongside the Procore permission that endpoint needs. + +That probe table is the fastest way to diagnose an empty shortcode: a valid token whose +service account lacks read permission on one specific tool is by far the most common +real-world failure. diff --git a/docs/blocks.md b/docs/blocks.md new file mode 100644 index 0000000..a1a926d --- /dev/null +++ b/docs/blocks.md @@ -0,0 +1,67 @@ +--- +title: Blocks +layout: default +nav_order: 5 +--- + +# Blocks + +Procore Connect registers a single **Procore** block with one inserter variation per shortcode. +Search the inserter for "Procore" and you will see entries for RFIs, submittals, the +project list and the rest. + +Blocks are on by default and can be turned off under **Procore → Tools**. Shortcodes work +either way, including inside the Shortcode block. + +## Using it + +1. Insert a **Procore: …** block, or insert **Procore** and pick what to show from the + sidebar. +2. Set the project or company ID in **Options**. +3. The preview renders live, using the same code the front end uses. + +The sidebar only offers the attributes that apply to the selected data type — an image +block shows a width control, a list block shows sorting and limits. Anything not surfaced +in the UI is still reachable through the equivalent shortcode. + +| Control | Applies to | +|---|---| +| Show | All — which data to render | +| Project ID / Company ID | All | +| Endpoint | The generic reader | +| Field / Label | Single field | +| Heading | Lists and records | +| Maximum rows | Lists | +| Filter by status | Project tools | +| Sort by field / direction | Lists | +| Columns | Lists | +| Image width | Featured image | +| Show email addresses | Team | +| Fetch every page | Lists | +| Extra CSS classes | All | + +Alignment, spacing and colour come from WordPress's own block supports. + +## How it renders + +The block is server-rendered: `save()` returns `null`, and the front end calls the same +shortcode handler. That means markup, escaping, caching and error handling are identical +between a block and a shortcode — there is no second rendering path to keep in sync. + +It also means credentials and cached payloads never reach the editor as block content; +the editor requests a rendered preview from the server like any other dynamic block. + +## No build step + +The editor script is hand-written ES5 against the global `wp.*` runtime. There is no +`node_modules`, no bundler and no compiled output. + +This is deliberate. Shipping a minified bundle without its source is a wordpress.org +review flag, it makes the plugin unauditable, and it puts Node between a contributor and +a one-line fix. The file in `blocks/procore/index.js` is the file that runs. + +## Turning blocks off + +**Procore → Tools → Register Procore Connect blocks.** Existing block content stops rendering +while this is off; converting those blocks to their shortcode equivalents first avoids +surprises. diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..4cec413 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,139 @@ +--- +title: Caching & rate limits +layout: default +nav_order: 6 +--- + +# Caching and rate limits + +## Why this matters + +Procore enforces two rate limits at once: an hourly window and a ten-second spike window. +The API returns whichever you are closer to breaching, along with three headers on every +response: + +| Header | Meaning | +|---|---| +| `X-Rate-Limit-Limit` | Requests allowed in the reported window | +| `X-Rate-Limit-Remaining` | Requests left in it | +| `X-Rate-Limit-Reset` | Unix timestamp when it resets | + +A page with six uncached Procore Connect shortcodes makes six or more API calls **per visitor**. +On any site with real traffic that exhausts the spike limit within minutes and the page +starts erroring for everyone. + +ProcoreWP 1.x had no caching at all — its own README acknowledged the problem and left it +to the reader. Version 2 caches by default. + +## How caching works + +Every response lands in two places: + +- a **fresh** entry with the endpoint's configured lifetime, which satisfies normal reads +- a **stale** copy with a much longer lifetime, used only when Procore is unreachable + +Storage goes through the WordPress transient API, so a site running Redis or Memcached +gets that behaviour automatically with no configuration. + +Cache keys include the endpoint, the request arguments, the company, the project **and the +environment** — so switching between production and a sandbox never serves data from the +other. + +### Default lifetimes + +| Data | Default | +|---|---| +| RFIs, submittals, punch list, observations | 10 minutes | +| Projects, project detail | 15 minutes | +| Team, vendors, daily logs, change orders, schedule | 30 minutes | +| Drawings, specifications, offices | 6 hours | +| Companies | 1 hour | + +Override per shortcode with `cache="900"`, or globally via the +[`procore_connect_endpoints`](../shortcodes/#the-generic-reader) filter. + +### The floor + +**Procore → Cache → Minimum cache lifetime** (default 300 seconds) is a hard floor. A +shortcode may request a *longer* lifetime but never a shorter one. + +This is deliberate: without it, one page author writing `cache="1"` could take the whole +site over the rate limit, and the person who has to fix it is not the person who wrote the +shortcode. + +## When Procore is unavailable + +Two mechanisms keep a published page from breaking. + +**Stale fallback.** If a request fails and a stale copy exists, that copy is served and +the failure is logged. A Procore outage degrades the freshness of a project page rather +than blanking it. + +**Circuit breaker.** After five consecutive failures, requests pause for five minutes and +cached data is served exclusively. This stops a site hammering an API that is already +struggling, and stops every page view paying a timeout. The state is shown on +**Procore → Status** and in an admin notice, and it clears itself. + +## Retries and backoff + +| Response | Behaviour | +|---|---| +| `429 Too Many Requests` | Wait for `Retry-After`, or `X-Rate-Limit-Reset`, then retry | +| `503 Service Unavailable` | Wait for `Retry-After`, then retry | +| `401 Unauthorized` | Discard the token and retry once with a fresh one | +| Transport failure | Exponential backoff with jitter | +| Other `4xx` | Fail immediately — retrying will not help | + +At most three attempts per request, and never longer than a ten-second pause. + +## Pagination + +Procore accepts `page` and `per_page` (keep it at or below 2000) and returns `Total`, +`Per-Page` and `Link` headers. + +With `all="true"`, Procore Connect walks the `Link: rel="next"` chain rather than incrementing +page numbers — following the links avoids the off-by-one at the end of the result set, +because the `next` link simply disappears on the final page. + +Two safeguards: a hard ceiling of 20 pages, so a misconfigured shortcode cannot pull tens +of thousands of records into memory (a warning is logged when it truncates); and any +`Link` pointing at a host other than the configured API host is refused outright. + +## Managing the cache + +**Admin** — **Procore → Cache** shows the entry count and offers a purge button. + +**WP-CLI** + +```bash +wp procore-connect cache-clear +wp procore-connect cache-clear --group=rfis +wp procore-connect cache-warm +``` + +**Cron** — an hourly job refreshes the project list for the default company, so the first +visitor after a cache expiry does not pay for the API call. Scheduled on activation, +removed on deactivation. + +**Programmatically** + +```php +// Disable caching entirely — not advisable on a public site. +add_filter( 'procore_connect_cache_enabled', '__return_false' ); + +// Purge one endpoint group after your own sync. +ProcoreConnect\Api\Cache::flush( 'projects' ); + +// React to a purge. +add_action( 'procore_connect_cache_flushed', function ( string $group, int $removed ): void { + error_log( "Procore Connect purged {$removed} entries from {$group}" ); +}, 10, 2 ); +``` + +## Monitoring + +**Procore → Status** reports remaining rate-limit headroom, circuit breaker state, token +expiry, cache statistics and whether a persistent object cache is in use. + +Turn on **Procore → Tools → Record API diagnostics** to keep the last 50 events. Client +secrets, tokens and authorization codes are redacted before anything is written. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a3bc448 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,92 @@ +--- +title: Home +layout: default +nav_order: 1 +--- + +# Procore Connect + +Publish live [Procore](https://www.procore.com/) construction project data on a WordPress +site — with shortcodes, blocks, and a cached REST proxy. + +Everything Procore Connect does is **read-only**. It never writes to Procore. + +``` +[procore_project_list company_id="4242" limit="10"] +[procore_project id="123"] +[procore_rfis id="123" status="open" limit="5"] +``` + +--- + +## Start here + +| | | +|---|---| +| [Installation](installation/) | Get the plugin running | +| [Authentication](authentication/) | Create a Procore app and connect | +| [Shortcodes](shortcodes/) | Full reference for all eighteen | +| [Blocks](blocks/) | Using Procore Connect in the block editor | +| [Caching & rate limits](caching/) | How Procore Connect stays inside Procore's quotas | +| [Templating & styling](templating/) | Change the markup and the CSS | +| [REST API](rest-api/) | The optional read-only proxy | +| [WP-CLI](wp-cli/) | Command line operations | +| [Troubleshooting](troubleshooting/) | When a shortcode shows nothing | +| [Upgrading from 1.x](upgrading/) | What changed and what you must do | + +--- + +## What it can display + +**Projects and company** — project directories, project detail panels, single project +fields, project images, team members, company vendors, company offices, and project +locations with geo microdata. + +**Documents** — drawing areas and specification sections. + +**Project tools** — RFIs, submittals, punch lists, observations, daily logs, change +orders and schedule milestones. + +**Anything else** — the generic `[procore_data]` shortcode renders any endpoint published +in the plugin's [endpoint registry](shortcodes/#the-generic-reader). + +--- + +## Why version 2 is a rewrite + +Version 1.x could not work against the live Procore API. It sent its token request to +`api.procore.com`, but Procore serves authentication from `login.procore.com` — a +different host — so no access token was ever issued, on any install, regardless of +configuration. + +Underneath that, it sent the company scope as a query parameter where Procore requires a +request header, stored the client secret as plaintext in an option loaded on every page +view, ran its connection test from an unverified `POST`, and cached nothing at all +against an API with a documented ten-second spike limit. + +Version 2.0.0 fixes all of that and adds two-mode authentication, caching, rate-limit +handling, encrypted credential storage, eleven new shortcodes, blocks, a REST proxy and +WP-CLI. The complete list is in the +[changelog](https://github.com/ibuilder/ProcoreWP/blob/main/CHANGELOG.md). + +--- + +## Requirements + +- WordPress 6.5 or later +- PHP 7.4 or later +- A Procore account with API access + +--- + +## Third-party service + +Procore Connect contacts Procore to retrieve the data you ask it to display: `login.procore.com` +for authentication and `api.procore.com` for data, or the sandbox or regional hosts you +configure. + +It sends your Client ID and Client Secret during authentication, plus the company and +project identifiers you configure. **No visitor data is sent to Procore.** + +[Procore terms of service](https://www.procore.com/legal/termsofservice) · +[Procore privacy policy](https://www.procore.com/legal/privacy) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c89657e --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,104 @@ +--- +title: Installation +layout: default +nav_order: 2 +--- + +# Installation + +## Requirements + +- WordPress 6.5 or later +- PHP 7.4 or later +- A Procore account with API access +- OpenSSL (present on essentially every host; without it, credentials cannot be encrypted + at rest and you should use the `wp-config.php` constants below instead) + +## Install the plugin + +### From a release ZIP + +1. Download the latest ZIP from + [Releases](https://github.com/ibuilder/ProcoreWP/releases). +2. In WordPress, go to **Plugins → Add New → Upload Plugin**. +3. Choose the ZIP, click **Install Now**, then **Activate**. + +### With Composer + +```bash +composer require ibuilder/procore-connect +``` + +### From source + +```bash +cd wp-content/plugins +git clone https://github.com/ibuilder/ProcoreWP.git procore-connect +``` + +No build step is required. The plugin ships ready to run — the block editor script is +hand-written ES5, not a compiled bundle. + +## First run + +1. Go to **Procore → Connection**. +2. Choose an authentication mode. Use **Service Account (Client Credentials)** unless you + have a specific reason not to — see [Authentication](../authentication/). +3. Enter your Client ID and Client Secret. +4. Click **Look up companies** and pick a default company. +5. Click **Test connection**. + +The connection test reports each stage separately and then probes every endpoint, so you +can see exactly which Procore tool permissions your credentials hold. That table is +usually the fastest answer to "why is my shortcode empty". + +## Keeping credentials out of the database + +By default the client secret is encrypted with AES-256-GCM before it is stored. For the +strongest protection, define it in `wp-config.php` instead — these constants take +precedence over the admin fields and are never written to an option: + +```php +define( 'PROCORE_CONNECT_CLIENT_ID', 'your-client-id' ); +define( 'PROCORE_CONNECT_CLIENT_SECRET', 'your-client-secret' ); +define( 'PROCORE_CONNECT_COMPANY_ID', 4242 ); +``` + +When these are set, the Connection screen shows a note in place of the credential fields. + +## Environments + +**Procore → Connection → Environment** selects which Procore hosts are used: + +| Environment | Login host | API host | +|---|---|---| +| Production | `login.procore.com` | `api.procore.com` | +| Developer Sandbox | `login-sandbox.procore.com` | `sandbox.procore.com` | +| Monthly Sandbox | `login-sandbox-monthly.procore.com` | `api-monthly.procore.com` | +| Custom | your own HTTPS host | your own HTTPS host | + +Each environment needs its own credentials — tokens are not shared between them. Choose +**Custom** for regional or federal zones and enter both hosts; only HTTPS is accepted. + +When a non-production environment is selected, an admin notice makes that obvious, so +nobody mistakes sandbox data for live data on a published page. + +## Adding data to a page + +Add a shortcode: + +``` +[procore_project_list limit="10"] +``` + +Or insert the **Procore** block and pick what to show from the sidebar. Both render +through the same code, so they produce identical markup. + +## Uninstalling + +Deleting the plugin removes its settings, credentials, tokens, cached responses and +diagnostic log. To keep them — for example when moving hosts — tick **Keep settings and +cached data when the plugin is deleted** under **Procore → Tools** before deleting. + +Deactivating (rather than deleting) clears the cache and the cron job but leaves settings +in place. diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 0000000..a4486ad --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,115 @@ +--- +title: REST API +layout: default +nav_order: 8 +--- + +# REST proxy + +An optional read-only proxy that lets your theme or front-end JavaScript read Procore +data without the browser ever seeing a Procore credential. + +**Off by default.** Enable it under **Procore → Tools → Expose cached Procore data**. + +## Access levels + +| Level | Who can read | +|---|---| +| Anyone | Unauthenticated visitors | +| Logged-in users | Any authenticated user *(default)* | +| Users who can edit posts | Anyone with `edit_posts` | + +Think about this before choosing **Anyone**. Even though only allow-listed endpoints are +reachable, a public route makes your project data machine-readable to anyone who finds +the URL. Whether that is a problem depends entirely on what is in your Procore account. + +## Endpoints + +### List what is available + +``` +GET /wp-json/procore-connect/v1/endpoints +``` + +```json +{ + "endpoints": [ + { "slug": "projects", "label": "Projects", "scope": "company", + "fields": [ "id", "name", "project_number", "city", "state_code", "active" ] }, + { "slug": "rfis", "label": "RFIs", "scope": "project", + "fields": [ "id", "number", "subject", "status", "due_date" ] } + ] +} +``` + +### Read data + +``` +GET /wp-json/procore-connect/v1/data/{endpoint} +``` + +| Parameter | Default | Notes | +|---|---|---| +| `company_id` | Site default | | +| `project_id` | — | Required for project-scoped endpoints | +| `per_page` | `100` | 1–2000 | +| `page` | `1` | | + +``` +GET /wp-json/procore-connect/v1/data/rfis?project_id=123&per_page=25 +``` + +```json +{ + "endpoint": "rfis", + "cached": true, + "stale": false, + "total": 42, + "data": [ { "id": 1, "number": "RFI-001", "subject": "Slab depth", "status": "open" } ] +} +``` + +`cached` tells you the response came from the plugin's cache rather than a live API call; +`stale` tells you Procore was unreachable and this is the last good copy. Responses also +carry an `X-Procore Connect-Cached` header. + +## Example + +```js +const response = await fetch( + '/wp-json/procore-connect/v1/data/rfis?project_id=123&per_page=25', + { credentials: 'same-origin', headers: { 'X-WP-Nonce': wpApiSettings.nonce } } +); + +const { data, cached } = await response.json(); +``` + +Omit the nonce only if the access level is set to **Anyone**. + +## What it will not do + +- **Write.** Every route is `GET`. There is no write path in the plugin at all. +- **Reach arbitrary endpoints.** Only slugs marked public in the endpoint registry + resolve. Identity endpoints such as `me` and `companies` are not published and return + 404. +- **Leak credentials.** The Client ID, Client Secret and tokens stay server-side. +- **Bypass the cache.** Requests are served from the same cache the shortcodes use, so + exposing the proxy cannot blow your Procore rate limit. +- **Leak error detail.** Procore's own error messages — which name accounts and + permissions — reach administrators only. Everyone else gets a neutral message. + +## Errors + +| Status | Meaning | +|---|---| +| `404` | The proxy is disabled, or the endpoint is not published | +| `401` / `403` | The access level excludes this request | +| `400` | Missing required context, e.g. no `project_id` on a project endpoint | +| `502` | Procore was unreachable and no cached copy existed | + +## Publishing your own endpoint + +Add it to the registry with `'public' => true` — see +[the shortcode reference](../shortcodes/#the-generic-reader). It becomes reachable from +both `[procore_data]` and this proxy, so only publish endpoints whose payload is safe to +expose at the access level you have chosen. diff --git a/docs/shortcodes.md b/docs/shortcodes.md new file mode 100644 index 0000000..c451715 --- /dev/null +++ b/docs/shortcodes.md @@ -0,0 +1,271 @@ +--- +title: Shortcodes +layout: default +nav_order: 4 +--- + +# Shortcode reference + +Every 1.x shortcode name is preserved, and the legacy `id` attribute still works as an +alias for `project_id`, so pages written for the old plugin keep rendering. + +A live version of this reference — generated from the same registry the code uses, so it +can never drift — is inside the plugin at **Procore → Shortcodes**. + +--- + +## Shared attributes + +Accepted by every shortcode. + +| Attribute | Default | Notes | +|---|---|---| +| `company_id` | Site default | Falls back to **Procore → Connection → Default company** | +| `project_id` | Site default | `id` is accepted as an alias | +| `limit` | `0` (all) | Caps the rendered rows | +| `page` | `1` | Which page of results to request | +| `orderby` | — | Any field path, e.g. `name` or `vendor.name` | +| `order` | `asc` | `asc` or `desc` | +| `columns` | Per shortcode | Comma-separated field paths | +| `template` | Per shortcode | Template name, overridable in your theme | +| `class` | — | Extra CSS classes, sanitized | +| `title` | Per shortcode | Heading text; pass `-` to omit the heading | +| `cache` | Endpoint default | Lifetime in seconds; cannot go below the site floor | +| `empty_text` | "No records were found." | Shown when nothing comes back | +| `show_email` | `false` | See [Privacy](#privacy) | +| `all` | `false` | Follow pagination and collect every page | + +Records missing the `orderby` field always sort last, in both directions. + +--- + +## Projects + +### `[procore_project_list]` + +A table of projects in a company. + +``` +[procore_project_list company_id="4242" limit="10" active_only="true" orderby="name"] +``` + +| Attribute | Default | Notes | +|---|---|---| +| `active_only` | `false` | Hide inactive projects | + +Default columns: ID, project name, project number, location, status. + +### `[procore_project]` + +One project as a labelled detail panel. Empty fields are omitted rather than rendered +blank. + +``` +[procore_project id="123"] +``` + +### `[procore_project_data]` + +A single field from a project record. + +``` +[procore_project_data id="123" field="start_date" label="Ground broken"] +[procore_project_data id="123" field="total_value" format="currency"] +``` + +| Attribute | Notes | +|---|---| +| `field` | **Required.** Must be allow-listed | +| `label` | Defaults to a title-cased version of the field name | +| `format` | `date`, `currency`, `number`, or omit to infer from the field name | + +Fields are allow-listed rather than arbitrary, because a Procore project payload contains +internal identifiers and personal data that should not be publishable by typing a field +name into a page. Fields ending `_date` or `_at` format as dates automatically; fields +ending `value`, `budget`, `total` or `amount` format as currency. + +To publish a field that is not on the list: + +```php +add_filter( 'procore_connect_allowed_project_fields', function ( array $fields ): array { + $fields[] = 'custom_field_name'; + return $fields; +} ); +``` + +### `[procore_featured_image]` + +The project logo or photo. + +``` +[procore_featured_image id="123" width="600"] +``` + +| Attribute | Default | +|---|---| +| `width` | `300` | +| `height` | auto | +| `alt` | The project name | +| `lazy` | `true` | + +### `[procore_project_map]` + +Projects that have coordinates, as an accessible list carrying geo microdata. + +``` +[procore_project_map limit="20"] +``` + +No mapping library is loaded. Bundling one would send visitor IP addresses to a +third party without consent, and remotely hosted assets are not permitted on +wordpress.org. Instead each item carries `data-latitude` and `data-longitude`, and the +container carries the full point set as JSON in `data-procore-connect-points` — so a theme can +attach whichever mapping provider the site already licenses. Each entry also links to +OpenStreetMap. + +--- + +## People and companies + +### `[procore_team]` + +``` +[procore_team id="123"] +[procore_team id="123" show_email="true"] +``` + +Columns: name, role, company, email. + +Email output requires **two** opt-ins — see [Privacy](#privacy). + +### `[procore_vendors]` + +Companies in the Procore company directory. + +``` +[procore_vendors orderby="name" limit="50"] +``` + +### `[procore_offices]` + +``` +[procore_offices] +``` + +--- + +## Documents + +### `[procore_drawings]` + +``` +[procore_drawings id="123" limit="5"] +``` + +### `[procore_specifications]` + +``` +[procore_specifications id="123" limit="10"] +``` + +--- + +## Project tools + +All accept `status` to filter, plus the shared attributes. + +``` +[procore_rfis id="123" status="open" limit="5"] +[procore_submittals id="123" orderby="due_date"] +[procore_punch_list id="123" status="open"] +[procore_observations id="123"] +[procore_daily_logs id="123" limit="7"] +[procore_change_orders id="123"] +[procore_milestones id="123"] +``` + +| Shortcode | Default columns | +|---|---| +| `procore_rfis` | Number, subject, status, due date | +| `procore_submittals` | Number, title, status, due date | +| `procore_punch_list` | Item, description, status, due date | +| `procore_observations` | Number, observation, status, due date | +| `procore_daily_logs` | Date, notes, status | +| `procore_change_orders` | Number, title, status, value | +| `procore_milestones` | Task, start, finish, percent complete | + +--- + +## The generic reader + +### `[procore_data]` + +Renders any endpoint published in the plugin's registry, without needing a bespoke +shortcode. + +``` +[procore_data endpoint="rfis" project_id="123" columns="number,subject,status,due_date"] +``` + +| Attribute | Notes | +|---|---| +| `endpoint` | **Required.** Must be a published registry slug | + +Published endpoints: `projects`, `project`, `project_users`, `project_vendors`, +`company_vendors`, `offices`, `drawing_areas`, `drawing_revisions`, +`specification_sections`, `rfis`, `submittals`, `punch_items`, `observations`, +`daily_logs`, `change_orders`, `milestones`. + +Identity endpoints such as `me` and `companies` are deliberately **not** published and +cannot be reached from a page. + +To register your own — useful when Procore bumps a resource version and a path changes: + +```php +add_filter( 'procore_connect_endpoints', function ( array $endpoints ): array { + $endpoints['meetings'] = array( + 'label' => 'Meetings', + 'path' => '/rest/v1.0/projects/{project_id}/meetings', + 'scope' => 'project', + 'paginated' => true, + 'ttl' => 900, + 'permission' => 'Meetings: Read Only', + 'fields' => array( 'id', 'title', 'date' ), + 'public' => true, + ); + return $endpoints; +} ); +``` + +Nothing outside this registry is callable. That boundary is what makes both +`[procore_data]` and the public REST proxy safe to expose. + +--- + +## Privacy + +Procore project directories contain personal data. Publishing them exposes staff and +subcontractors to address harvesting, so Procore Connect will not output an email address +unless **both** of the following are true: + +1. **Procore → Display → Never output email addresses** is unticked, and +2. the individual shortcode carries `show_email="true"`. + +Even then, addresses pass through `antispambot()`. + +Procore's own error messages routinely name accounts, projects and permissions. Those are +shown to users with `manage_options` only; everyone else sees a neutral notice. + +--- + +## Caching per shortcode + +`cache` sets a lifetime in seconds for that shortcode's data: + +``` +[procore_rfis id="123" cache="300"] +``` + +It can only lengthen the lifetime, never shorten it below the site floor set in +**Procore → Cache**. A page author cannot configure a page into a rate-limit breach. See +[Caching & rate limits](../caching/). diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 0000000..83ebd6e --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,175 @@ +--- +title: Templating & styling +layout: default +nav_order: 7 +--- + +# Templating and styling + +## Do not edit the plugin + +ProcoreWP 1.x told you to edit `assets/css/procore-integration.css` inside the plugin +folder. Every plugin update overwrote it and your work was gone. + +There are two supported ways to change the output, and both survive updates. + +## Custom CSS + +**Procore → Display → Custom CSS.** Added inline after the plugin stylesheet, so your +rules win without needing `!important`. Markup is stripped on save. + +```css +.procore-connect-table thead th { + background: #0d3b66; + color: #fff; +} + +.procore-connect-status--active { + background: #d1f4dd; +} +``` + +To drop the plugin stylesheet entirely and style everything from your theme, untick +**Load the Procore Connect stylesheet**. + +## Template overrides + +Copy a template out of the plugin and into your theme: + +``` +wp-content/plugins/procore-connect/templates/collection.php + → wp-content/themes/your-theme/procore-connect/collection.php +``` + +The lookup order is child theme, then parent theme, then the plugin's own default. + +| Template | Used by | +|---|---| +| `collection.php` | Every list shortcode | +| `record.php` | `[procore_project]` | +| `field.php` | `[procore_project_data]` | +| `image.php` | `[procore_featured_image]` | +| `map.php` | `[procore_project_map]` | + +### Per-shortcode templates + +Add a file and point a shortcode at it: + +``` +[procore_rfis id="123" template="rfi-cards"] +``` + +Resolves to `yourtheme/procore-connect/rfi-cards.php`. + +### What a template receives + +Everything arrives in `$data`. + +```php +$data['rows'] // array of records (collection templates) +$data['record'] // single record (record template) +$data['columns'] // column definitions: key, label, format +$data['title'] // heading text +$data['class'] // sanitized class attribute +$data['show_email'] // whether the shortcode opted in to email output +$data['atts'] // the sanitized shortcode attributes +$data['tag'] // the shortcode tag +``` + +### Escaping + +Get cell content from `Format::cell()`. It escapes everything it returns, and the only +markup it emits is a status badge or a link. + +```php + +
    + +
  • + + + + +
  • + +
+``` + +Keeping every value on that one path is what makes the escaping guarantee auditable. +Reaching into `$row` and echoing raw values reintroduces exactly the XSS surface the +rewrite closed — Procore field values are user-entered data. + +### Reading fields directly + +`Arr` handles the sparse payloads Procore returns; it tolerates missing keys rather than +warning. + +```php +use ProcoreConnect\Support\Arr; +use ProcoreConnect\Support\Format; + +echo esc_html( Arr::str( $row, 'vendor.name', 'Unassigned' ) ); +echo esc_html( Format::date( Arr::get( $row, 'due_date' ) ) ); +echo esc_html( Format::location( $row ) ); +``` + +## Changing columns + +Without touching a template: + +``` +[procore_rfis id="123" columns="number,subject,assignee.name,due_date"] +``` + +Unknown fields get a title-cased label and plain text formatting. To change a default +column map permanently: + +```php +add_filter( 'procore_connect_shortcodes', function ( array $shortcodes ): array { + $shortcodes['procore_rfis']['columns'] = array( + array( 'key' => 'number', 'label' => 'RFI', 'format' => 'text' ), + array( 'key' => 'subject', 'label' => 'Query', 'format' => 'text' ), + array( 'key' => 'due_date', 'label' => 'Due', 'format' => 'date' ), + ); + return $shortcodes; +} ); +``` + +### Column formats + +| Format | Renders | +|---|---| +| `text` | Escaped text; related records use their `name`/`title` | +| `date` | Site date format, or the plugin override | +| `currency` | Configured symbol plus a localised number | +| `percent` | Rounded, with a `%` sign | +| `status` | An Active/Inactive badge | +| `email` | A `mailto:` link, subject to both privacy opt-ins | +| `url` | An external link labelled with the hostname | + +The special key `__location` composes city, state and country from the record. + +## Styling reference + +| Class | Element | +|---|---| +| `.procore-connect` | Every wrapper | +| `.procore-connect-title` | Headings | +| `.procore-connect-table-wrap` | Horizontal scroll container | +| `.procore-connect-table` | Tables | +| `.procore-connect-details` / `.procore-connect-detail` | Detail lists | +| `.procore-connect-field__label` / `__value` | Single fields | +| `.procore-connect-status--active` / `--inactive` | Status badges | +| `.procore-connect-map__list` / `__item` / `__name` | Location lists | +| `.procore-connect-notice--error` / `--empty` | Notices | + +The bundled stylesheet collapses tables into stacked rows below 600px, using each cell's +`data-label`, and respects `prefers-color-scheme: dark`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..fa17466 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,200 @@ +--- +title: Troubleshooting +layout: default +nav_order: 10 +--- + +# Troubleshooting + +## Start here + +**Procore → Connection → Test connection.** + +It reports six stages separately and then probes every endpoint. Nearly every problem +below is identified by that screen in one click, so run it before reading further. The +same thing is available as `wp procore-connect test`. + +--- + +## "My shortcode shows nothing" + +Work down this list. + +### 1. The service account lacks that tool permission + +By far the most common cause. A perfectly valid token can read projects and still get 403 +on RFIs, because the app manifest never declared the RFIs permission. + +The endpoint probe table shows `failed` against that endpoint. Fix it in Procore: update +the app's Permissions Builder declaration and have a company administrator reinstall or +update the app. Adjusting the service account by hand in the Directory tool creates +inconsistencies and is not a fix. + +### 2. Nothing to show + +Not every project has drawings, specifications or open RFIs. Check in Procore directly. A +`0 records` result and a permission failure look identical from the front end — but the +probe reports the first as `ok`. + +### 3. Wrong project or company ID + +They come from the Procore URL: + +``` +https://app.procore.com/companies/4242/... → company 4242 +https://app.procore.com/projects/123/... → project 123 +``` + +Or run `wp procore-connect projects`. + +### 4. The default company is not set + +Project-scoped shortcodes still need a company for the `Procore-Company-Id` header. Set +one under **Connection**, or pass `company_id` explicitly. + +### 5. You are looking at a sandbox + +If **Environment** is not Production, the site is showing sandbox data. An admin notice +says so. + +### 6. Stale cache + +Data changed in Procore but not on the page. Clear the cache under **Procore → Cache**, +or `wp procore-connect cache-clear`. + +**Log in as an administrator and reload the page.** Administrators see the real Procore +error message inline; visitors see a neutral notice. That one step usually ends the +investigation. + +--- + +## Connection problems + +### "Enter your Procore Client ID and Client Secret" + +Neither field is populated. If you set them in `wp-config.php`, check the constant names — +`PROCORE_CONNECT_CLIENT_ID` and `PROCORE_CONNECT_CLIENT_SECRET` — and that they are defined before +`wp-settings.php` is required. + +### "Procore rejected the authentication request" + +- Credentials belong to a different environment. Production, Developer Sandbox and + Monthly Sandbox each need their own. +- The app has no OAuth component with the `client_credentials` grant type. +- The secret was regenerated in the Developer Portal. +- The secret was truncated on paste. Re-enter it — the field shows a mask, and leaving it + blank keeps the old value rather than clearing it. + +### "Procore returned no companies" + +The credentials authenticate but no company has installed the app. A Procore company +administrator has to install it and select which projects it may access. + +### "The Procore connection has expired" + +Authorization Code mode, and the refresh token was rejected. Procore invalidates a refresh +token the moment it is used, so this happens if the token was rotated elsewhere or the +user's Procore access was revoked. Click **Connect to Procore** again. + +This is a good reason to prefer the service account mode on any unattended site. + +### The redirect URI is rejected + +It must match what is registered in the Developer Portal **exactly** — scheme, `www`, path +and query string. Copy it from the Connection screen rather than typing it. + +### "Another request is currently authenticating" + +Two requests tried to refresh at once and one waited. Harmless and self-clearing. If it +persists, an object cache may be dropping the lock; `wp procore-connect reset-token` clears the +state. + +--- + +## Rate limits + +### "Procore is rate limiting this site" + +Check remaining headroom on **Procore → Status**. + +- Confirm caching is on under **Procore → Cache** — it is the single biggest factor. +- Raise the minimum cache lifetime. +- Reduce how many different shortcodes appear on one page. +- Look for a shortcode with a low `cache` value or `all="true"` on a large data set. + +### "Procore requests are paused after repeated failures" + +The circuit breaker opened after five consecutive failures. It resumes automatically after +five minutes, serving cached data meanwhile. Clearing the cache also resets it. + +Consecutive failures usually mean a credential or permission problem, not a Procore +outage — run the connection test. + +--- + +## Display problems + +### Emails are missing + +Working as intended. Publishing them needs **both** the global setting unticked under +**Display** and `show_email="true"` on the shortcode. + +### Dates or currency look wrong + +**Procore → Display** sets a date format override and a currency symbol. Blank uses the +site's own date format. + +### Styling is missing + +The stylesheet loads only on pages containing Procore Connect output. If output comes from a +widget or a direct `do_shortcode()` call in a template, it loads in the footer instead of +the head. Check that **Load the Procore Connect stylesheet** is on under **Display**. + +### A template override is ignored + +- Path: `wp-content/themes/your-theme/procore-connect/collection.php` +- The filename must match the template name exactly, lowercase, `.php` extension +- A child theme takes precedence over its parent + +--- + +## After upgrading from 1.x + +### The plugin looks deactivated + +Expected. The main file was renamed from `index.php` to `procore-connect.php`, so WordPress sees +a different plugin. Activate Procore Connect once — your settings are imported automatically. + +### It still does not connect + +Version 1.x never obtained a token, because it authenticated against the wrong host. Any +credentials it stored were never validated against a real Procore response. Re-run the +connection test; it will show which stage fails. + +Also rotate the client secret. Version 1.x stored it as plaintext in an autoloaded option, +so treat it as exposed. + +--- + +## Gathering diagnostics + +Turn on **Procore → Tools → Record API diagnostics**, reproduce the problem, then read the +last 50 events on **Procore → Status**. Secrets, tokens and authorization codes are +redacted before anything is written. + +With `WP_DEBUG` and `WP_DEBUG_LOG` on, the same entries also go to `debug.log`. + +When reporting an issue, `wp procore-connect doctor` output is the most useful single thing to +include — it reports whether credentials are set without printing them. + +--- + +## Still stuck? + +Open an issue at +[github.com/ibuilder/ProcoreWP/issues](https://github.com/ibuilder/ProcoreWP/issues) with +the `doctor` output, which endpoint probes failed, and the shortcode you are using. + +Please redact any real credentials. For a security problem, use +[private reporting](https://github.com/ibuilder/ProcoreWP/security/advisories/new) +instead. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..115866b --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,132 @@ +--- +title: Upgrading from 1.x +layout: default +nav_order: 11 +--- + +# Upgrading from 1.x + +## Two things you must do + +### 1. Activate the plugin once + +Two renames happened, and both were unavoidable. + +**The plugin is now called Procore Connect**, with the slug `procore-connect`. The old +name contained "wp", which wordpress.org disallows outright in both a plugin name and a +slug — keeping it would have ruled out the plugin directory permanently. The GitHub +repository is unchanged and still lives at +[ibuilder/ProcoreWP](https://github.com/ibuilder/ProcoreWP). + +**The main file moved** from `index.php` to `procore-connect.php`, because a main file +called `index.php` fails Plugin Check. WordPress identifies a plugin by its file path, so +it treats 2.0.0 as a different plugin and shows it as inactive. + +Go to **Plugins**, find **Procore Connect**, and activate it. Your settings are imported +automatically by the upgrade routine — you do not need to re-enter anything. + +### 2. Re-run the connection test + +**Procore → Connection → Test connection.** + +Version 1.x sent its token request to `api.procore.com/oauth/token`. Procore serves +authentication from `login.procore.com/oauth/token` — a different host — so no token was +ever issued, on any 1.x install. Whatever credentials you had were never validated against +a real Procore response, so verify them now rather than assuming an empty shortcode is a +new bug. + +### And rotate your secret + +Version 1.x stored the client secret, access token and refresh token as plaintext in an +autoloaded WordPress option — read into memory on every single page request, and present +in every database backup. Treat that secret as exposed: regenerate it in the Procore +Developer Portal and enter the new one. + +--- + +## What keeps working + +**All of it.** Every 1.x shortcode tag is preserved, and the legacy `id` attribute is +still accepted as an alias for `project_id`. This page from 1.x renders unchanged: + +``` +[procore_featured_image id="123" company_id="456" width="600"] +[procore_project id="123" company_id="456"] +[procore_team id="123" company_id="456"] +[procore_drawings id="123" company_id="456" limit="5"] +[procore_project_data id="123" company_id="456" field="budget" label="Budget"] +[procore_project_list company_id="456" show_details="true" active_only="true"] +``` + +Your Client ID, Client Secret and default company ID are migrated from the old +`procore_integration_settings` option. The secret is re-encrypted on the way in, and the +old plaintext option is deleted. The stale token is discarded, since it was never valid. + +--- + +## Behaviour changes worth knowing + +| | 1.x | 2.0.0 | +|---|---|---| +| Custom CSS | Edit files in the plugin folder | **Procore → Display → Custom CSS**, or a theme template override | +| Email addresses | Published by default | Suppressed by default; needs two opt-ins | +| API errors on the front end | Shown to everyone | Administrators only | +| `limit="0"` | Returned page one, silently | Returns page one; add `all="true"` for every page | +| Settings location | Settings → Procore Integration | A top-level **Procore** menu | +| Caching | None | On by default | + +### Custom CSS will be lost + +If you edited `assets/css/procore-integration.css` inside the plugin folder — which the +1.x README instructed — those changes are gone, as they were on every previous update. + +Copy them into **Procore → Display → Custom CSS** before or after upgrading. Class names +have changed from `procore-*` to `procore-connect-*`; the mapping is in +[Templating & styling](../templating/#styling-reference). + +### Email addresses stop appearing + +`[procore_team]` published every member's email address on a public page by default. It no +longer does. To restore the old behaviour, untick **Never output email addresses** under +**Display** *and* add `show_email="true"` to the shortcode. + +Worth thinking about before you do: those addresses belong to staff and subcontractors who +did not choose to have them published. + +### A stale page may look different + +With caching on, a project page can be up to fifteen minutes behind Procore by default. +Adjust lifetimes under **Procore → Cache**, but do not disable caching on a public site — +[the reason why](../caching/#why-this-matters). + +--- + +## Rolling back + +Deactivate 2.0.0 and reactivate the 1.x plugin. Its option was deleted during migration, +so you would need to re-enter credentials. + +This is not recommended. Version 1.x cannot authenticate against Procore's live API, and +it stores your credentials in plaintext. + +--- + +## Removing 1.x files + +Once 2.0.0 is running, delete the old plugin directory +(`wp-content/plugins/procore-integration/`) from the Plugins screen. Its uninstall routine +in 2.0.0 also removes the legacy `procore_integration_settings` option if any trace +remains. + +--- + +## Fresh install instead + +If it is simpler, you can start clean: + +1. Note your Client ID, Client Secret and company ID. +2. Delete the 1.x plugin. +3. Install 2.0.0 and follow [Installation](../installation/). +4. Rotate the client secret in the Developer Portal. + +Your existing pages and shortcodes need no changes either way. diff --git a/docs/wp-cli.md b/docs/wp-cli.md new file mode 100644 index 0000000..f8329c6 --- /dev/null +++ b/docs/wp-cli.md @@ -0,0 +1,110 @@ +--- +title: WP-CLI +layout: default +nav_order: 9 +--- + +# WP-CLI + +All commands live under `wp procore-connect` and are available whenever WP-CLI is. + +## Verify the connection + +```bash +wp procore-connect test +``` + +Runs the same diagnostic as the admin screen: configuration, token, identity, company +access, default company, rate limit — then probes every endpoint. + +``` ++------------------------+--------+------------------------------------------------+ +| check | result | detail | ++------------------------+--------+------------------------------------------------+ +| Configuration | OK | Ready. Environment: Production (api.procore..) | +| Access token | OK | Obtained via Service Account. Valid for 1 hour | +| Authenticated account | OK | Connected as Service Account (dmsa@...) | +| Company access | OK | 1 company available: Example Construction (42) | +| Default company | OK | 37 projects visible in company 4242 | +| Rate limit | OK | 3211 of 3600 requests remaining | ++------------------------+--------+------------------------------------------------+ + +Endpoint permissions: ++------------------------+---------+-------------+--------------------------------+ +| slug | status | message | permission | ++------------------------+---------+-------------+--------------------------------+ +| projects | ok | Readable. | Company Admin: Read Only | +| rfis | ok | Readable. | RFIs: Read Only | +| submittals | failed | HTTP 403 | Submittals: Read Only | ++------------------------+---------+-------------+--------------------------------+ +``` + +A `failed` row almost always means the service account was not granted that tool +permission at install time. Fix it in Procore's App Management, not here. + +Exits non-zero when a check fails, so it works as a deploy gate: + +```bash +wp procore-connect test || exit 1 +``` + +## Configuration summary + +```bash +wp procore-connect doctor +``` + +Prints environment, hosts, redirect URI, auth mode, whether credentials are set, default +company, cache state and circuit breaker state. Secrets are reported as `yes`/`no`, never +printed — safe to paste into a support ticket. + +## List projects + +```bash +wp procore-connect projects +wp procore-connect projects --company=4242 --format=csv +wp procore-connect projects --format=json > projects.json +``` + +Follows pagination, so this returns every project rather than the first page. + +| Option | Default | Notes | +|---|---|---| +| `--company=` | Site default | | +| `--format=` | `table` | `table`, `csv`, `json`, `count` | + +## Cache + +```bash +wp procore-connect cache-clear +wp procore-connect cache-clear --group=rfis +wp procore-connect cache-warm +``` + +`cache-warm` fetches every project for the default company and caches the result, so the +first visitor after a deploy does not pay for the API call. Useful in a deploy script: + +```bash +wp procore-connect cache-clear +wp procore-connect cache-warm +``` + +## Reset the token + +```bash +wp procore-connect reset-token +``` + +Discards stored tokens and resets the circuit breaker. The next request re-authenticates. + +For the service account grant this is harmless — a new token is simply requested. For the +user grant it clears the refresh token too, so an administrator has to click **Connect to +Procore** again. + +## In multisite + +```bash +wp procore-connect test --url=https://example.com/site-two +``` + +Settings, credentials and cache are per-site. diff --git a/includes/activation.php b/includes/activation.php deleted file mode 100644 index 2ae975c..0000000 --- a/includes/activation.php +++ /dev/null @@ -1,44 +0,0 @@ -settings = get_option('procore_integration_settings', [ - 'client_id' => '', - 'client_secret' => '', - 'api_url' => 'https://api.procore.com', - 'token' => '', - 'token_expires' => 0, - 'refresh_token' => '', - 'default_company_id' => '', - ]); - - // Initialize hooks - $this->init_hooks(); - } - - /** - * Get singleton instance - */ - public static function get_instance() { - if (null === self::$instance) { - self::$instance = new self(); - } - return self::$instance; - } - - /** - * Initialize WordPress hooks - */ - private function init_hooks() { - // Admin hooks - add_action('admin_menu', [$this, 'add_settings_page']); - add_action('admin_init', [$this, 'register_settings']); - - // Register shortcodes - add_shortcode('procore_project', [$this, 'project_shortcode']); - add_shortcode('procore_team', [$this, 'team_shortcode']); - add_shortcode('procore_featured_image', [$this, 'featured_image_shortcode']); - add_shortcode('procore_drawings', [$this, 'drawings_shortcode']); - add_shortcode('procore_specifications', [$this, 'specifications_shortcode']); - add_shortcode('procore_project_data', [$this, 'project_data_shortcode']); - add_shortcode('procore_project_list', [$this, 'project_list_shortcode']); - } - - /** - * Add settings page to WordPress admin - */ - public function add_settings_page() { - add_options_page( - 'Procore Integration Settings', - 'Procore Integration', - 'manage_options', - 'procore-integration', - [$this, 'render_settings_page'] - ); - } - - /** - * Register plugin settings - */ - public function register_settings() { - register_setting('procore_integration_settings_group', 'procore_integration_settings'); - - add_settings_section( - 'procore_integration_main_section', - 'API Configuration', - [$this, 'render_main_section'], - 'procore-integration' - ); - - add_settings_field( - 'procore_client_id', - 'Client ID', - [$this, 'render_client_id_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - - add_settings_field( - 'procore_client_secret', - 'Client Secret', - [$this, 'render_client_secret_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - - add_settings_field( - 'procore_api_url', - 'API URL', - [$this, 'render_api_url_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - - add_settings_field( - 'procore_default_company_id', - 'Default Company ID', - [$this, 'render_default_company_id_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - } - - /** - * Render main settings section - */ - public function render_main_section() { - echo '

Enter your Procore API credentials below. Learn more about Procore API authentication.

'; - } - - /** - * Render client ID field - */ - public function render_client_id_field() { - $value = isset($this->settings['client_id']) ? $this->settings['client_id'] : ''; - echo ''; - } - - /** - * Render client secret field - */ - public function render_client_secret_field() { - $value = isset($this->settings['client_secret']) ? $this->settings['client_secret'] : ''; - echo ''; - } - - /** - * Render API URL field - */ - public function render_api_url_field() { - $value = isset($this->settings['api_url']) ? $this->settings['api_url'] : 'https://api.procore.com'; - echo ''; - } - - /** - * Render default company ID field - */ - public function render_default_company_id_field() { - $value = isset($this->settings['default_company_id']) ? $this->settings['default_company_id'] : ''; - echo ''; - echo '

Enter your default Procore Company ID. This will be used if not specified in shortcodes.

'; - } - - /** - * Render settings page - */ - public function render_settings_page() { - if (!current_user_can('manage_options')) { - return; - } - - // Check if we need to test the connection - if (isset($_POST['test_connection'])) { - $this->test_connection(); - } - - // Display settings form - ?> -
-

-
- -
- -
- -

Test Connection

-

Click the button below to test your Procore API connection.

-
- -
- -
- -

Shortcode Reference

-

Use these shortcodes to display Procore information on your WordPress site:

-
    -
  • [procore_project_list company_id="123"] - Display a list of all project IDs and names
  • -
  • [procore_project id="123" company_id="123"] - Display project information
  • -
  • [procore_team id="123" company_id="123"] - Display project team members
  • -
  • [procore_featured_image id="123" company_id="123"] - Display project featured image
  • -
  • [procore_drawings id="123" company_id="123"] - Display project drawings
  • -
  • [procore_specifications id="123" company_id="123"] - Display project specifications
  • -
  • [procore_project_data id="123" company_id="123" field="field_name"] - Display specific project data
  • -
-
- get_token(); - - if (is_wp_error($result)) { - add_settings_error( - 'procore_integration', - 'connection_error', - 'Connection failed: ' . $result->get_error_message(), - 'error' - ); - } else { - add_settings_error( - 'procore_integration', - 'connection_success', - 'Connection successful! Your authentication token has been updated.', - 'success' - ); - } - } - - /** - * Get OAuth token from Procore - */ - private function get_token() { - // Check if we have a valid token - if (!empty($this->settings['token']) && $this->settings['token_expires'] > time()) { - return true; - } - - // Check if we have a refresh token - if (!empty($this->settings['refresh_token'])) { - return $this->refresh_token(); - } - - // Otherwise get a new token - $client_id = $this->settings['client_id']; - $client_secret = $this->settings['client_secret']; - $api_url = $this->settings['api_url']; - - if (empty($client_id) || empty($client_secret)) { - return new WP_Error('missing_credentials', 'Client ID and Client Secret are required'); - } - - $response = wp_remote_post($api_url . '/oauth/token', [ - 'body' => [ - 'grant_type' => 'client_credentials', - 'client_id' => $client_id, - 'client_secret' => $client_secret, - ], - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - ]); - - if (is_wp_error($response)) { - return $response; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - - if (isset($body['error'])) { - return new WP_Error('auth_error', $body['error_description'] ?? $body['error']); - } - - if (empty($body['access_token'])) { - return new WP_Error('invalid_response', 'Invalid response from Procore API'); - } - - // Update settings with new token - $this->settings['token'] = $body['access_token']; - $this->settings['token_expires'] = time() + ($body['expires_in'] ?? 7200); - $this->settings['refresh_token'] = $body['refresh_token'] ?? ''; - update_option('procore_integration_settings', $this->settings); - - return true; - } - - /** - * Refresh OAuth token - */ - private function refresh_token() { - $client_id = $this->settings['client_id']; - $client_secret = $this->settings['client_secret']; - $api_url = $this->settings['api_url']; - $refresh_token = $this->settings['refresh_token']; - - $response = wp_remote_post($api_url . '/oauth/token', [ - 'body' => [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $refresh_token, - 'client_id' => $client_id, - 'client_secret' => $client_secret, - ], - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - ]); - - if (is_wp_error($response)) { - return $response; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - - if (isset($body['error'])) { - // If refresh token fails, clear it and try to get a new token - $this->settings['refresh_token'] = ''; - update_option('procore_integration_settings', $this->settings); - return $this->get_token(); - } - - if (empty($body['access_token'])) { - return new WP_Error('invalid_response', 'Invalid response from Procore API'); - } - - // Update settings with new token - $this->settings['token'] = $body['access_token']; - $this->settings['token_expires'] = time() + ($body['expires_in'] ?? 7200); - $this->settings['refresh_token'] = $body['refresh_token'] ?? ''; - update_option('procore_integration_settings', $this->settings); - - return true; - } - - /** - * Make a request to the Procore API - */ - private function api_request($endpoint, $method = 'GET', $data = null, $company_id = null) { - // Get token - $token_result = $this->get_token(); - if (is_wp_error($token_result)) { - return $token_result; - } - - $api_url = $this->settings['api_url']; - $token = $this->settings['token']; - - // Use company ID if provided, otherwise use default - if ($company_id === null) { - $company_id = $this->settings['default_company_id']; - } - - // Add company ID to endpoint if it's not already included - if (!empty($company_id) && strpos($endpoint, 'company_id=') === false) { - $endpoint .= (strpos($endpoint, '?') === false) ? '?' : '&'; - $endpoint .= 'company_id=' . $company_id; - } - - $args = [ - 'method' => $method, - 'headers' => [ - 'Authorization' => 'Bearer ' . $token, - 'Content-Type' => 'application/json', - ], - ]; - - if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'])) { - $args['body'] = json_encode($data); - } - - $response = wp_remote_request($api_url . $endpoint, $args); - - if (is_wp_error($response)) { - return $response; - } - - $code = wp_remote_retrieve_response_code($response); - $body = json_decode(wp_remote_retrieve_body($response), true); - - if ($code >= 400) { - return new WP_Error( - 'api_error', - 'API Error (' . $code . '): ' . ($body['message'] ?? 'Unknown error') - ); - } - - return $body; - } - - /** - * Get project details - */ - private function get_project($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id, 'GET', null, $company_id); - } - - /** - * Get project team members - */ - private function get_project_team($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id . '/users', 'GET', null, $company_id); - } - - /** - * Get project drawings - */ - private function get_project_drawings($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id . '/drawing_areas', 'GET', null, $company_id); - } - - /** - * Get project specifications - */ - private function get_project_specifications($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id . '/specification_sections', 'GET', null, $company_id); - } - - /** - * Get project image - */ - private function get_project_image($project_id, $company_id = null) { - $project = $this->get_project($project_id, $company_id); - if (is_wp_error($project)) { - return $project; - } - - // Check if project has a logo - if (!empty($project['logo_url'])) { - return $project['logo_url']; - } - - return ''; - } - - /** - * Get all available projects - */ - private function get_projects($company_id = null) { - return $this->api_request('/rest/v1.0/projects', 'GET', null, $company_id); - } - - /** - * Project information shortcode - */ - public function project_shortcode($atts) { - $atts = shortcode_atts([ - 'id' => '', - 'company_id' => '', - ], $atts, 'procore_project'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $project = $this->get_project($atts['id'], $atts['company_id']); - if (is_wp_error($project)) { - return '

Error: ' . esc_html($project->get_error_message()) . '

'; - } - - ob_start(); - ?> -
-

-
-

Address:

-

City:

-

State:

-

Zip:

-

Start Date:

-

Completion Date:

-

Status:

-
-
- '', - 'company_id' => '', - ], $atts, 'procore_team'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $team = $this->get_project_team($atts['id'], $atts['company_id']); - if (is_wp_error($team)) { - return '

Error: ' . esc_html($team->get_error_message()) . '

'; - } - - if (empty($team)) { - return '

No team members found for this project.

'; - } - - ob_start(); - ?> -
-

Project Team

-
    - -
  • -
    -
    -
    -
  • - -
-
- '', - 'company_id' => '', - 'width' => '300', - 'height' => 'auto', - ], $atts, 'procore_featured_image'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $image_url = $this->get_project_image($atts['id'], $atts['company_id']); - if (is_wp_error($image_url)) { - return '

Error: ' . esc_html($image_url->get_error_message()) . '

'; - } - - if (empty($image_url)) { - return '

No featured image available for this project.

'; - } - - ob_start(); - ?> -
- Project Image -
- '', - 'company_id' => '', - 'limit' => 10, - ], $atts, 'procore_drawings'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $drawings = $this->get_project_drawings($atts['id'], $atts['company_id']); - if (is_wp_error($drawings)) { - return '

Error: ' . esc_html($drawings->get_error_message()) . '

'; - } - - if (empty($drawings)) { - return '

No drawings found for this project.

'; - } - - // Limit the number of drawings - $limit = intval($atts['limit']); - if ($limit > 0 && count($drawings) > $limit) { - $drawings = array_slice($drawings, 0, $limit); - } - - ob_start(); - ?> -
-

Project Drawings

-
    - -
  • -
    - -
    - -
  • - -
-
- '', - 'company_id' => '', - 'limit' => 10, - ], $atts, 'procore_specifications'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $specs = $this->get_project_specifications($atts['id'], $atts['company_id']); - if (is_wp_error($specs)) { - return '

Error: ' . esc_html($specs->get_error_message()) . '

'; - } - - if (empty($specs)) { - return '

No specifications found for this project.

'; - } - - // Limit the number of specifications - $limit = intval($atts['limit']); - if ($limit > 0 && count($specs) > $limit) { - $specs = array_slice($specs, 0, $limit); - } - - ob_start(); - ?> -
-

Project Specifications

-
    - -
  • -
    -
    - -
    - -
  • - -
-
- '', - 'company_id' => '', - 'field' => '', - 'label' => '', - ], $atts, 'procore_project_data'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - if (empty($atts['field'])) { - return '

Error: Field name is required

'; - } - - $project = $this->get_project($atts['id'], $atts['company_id']); - if (is_wp_error($project)) { - return '

Error: ' . esc_html($project->get_error_message()) . '

'; - } - - $field = $atts['field']; - $value = isset($project[$field]) ? $project[$field] : ''; - - if (empty($value)) { - return '

No data available for field "' . esc_html($field) . '"

'; - } - - // Format value based on type - if (is_array($value)) { - $value = implode(', ', $value); - } elseif (is_bool($value)) { - $value = $value ? 'Yes' : 'No'; - } - - $label = !empty($atts['label']) ? $atts['label'] : ucwords(str_replace('_', ' ', $field)); - - ob_start(); - ?> -
- : - -
- 0, - 'company_id' => '', - 'show_details' => 'false', - 'active_only' => 'true', - 'sort_by' => 'name', // name, id, created_at - 'sort_order' => 'asc', // asc, desc - ], $atts, 'procore_project_list'); - - $projects = $this->get_projects($atts['company_id']); - if (is_wp_error($projects)) { - return '

Error: ' . esc_html($projects->get_error_message()) . '

'; - } - - if (empty($projects)) { - return '

No projects found.

'; - } - - // Filter projects by active status if needed - if ($atts['active_only'] === 'true') { - $projects = array_filter($projects, function($project) { - return isset($project['active']) && $project['active'] === true; - }); - } - - // Sort projects - $sort_by = $atts['sort_by']; - $sort_order = strtolower($atts['sort_order']) === 'desc' ? SORT_DESC : SORT_ASC; - - if (in_array($sort_by, ['name', 'id', 'created_at'])) { - $sort_column = array_column($projects, $sort_by); - array_multisort($sort_column, $sort_order, $projects); - } - - // Limit the number of projects if needed - $limit = intval($atts['limit']); - if ($limit > 0 && count($projects) > $limit) { - $projects = array_slice($projects, 0, $limit); - } - - $show_details = $atts['show_details'] === 'true'; - - ob_start(); - ?> -
-

Procore Projects

- - - - - - - - - - - - - - - - - - - - - - - - -
IDProject NameLocationStatus
- - - -
-
- \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: 2026-08-07T00:00:00+00:00\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Procore Connect build script\n" +"X-Domain: procore-connect\n" + +#. translators: 1: number of companies, 2: comma-separated company names. +#: src/Admin/ConnectionTester.php:193 +msgid "%1$d company available: %2$s" +msgid_plural "%1$d companies available: %2$s" +msgstr[0] "" +msgstr[1] "" + +#. translators: 1: remaining requests, 2: total allowed requests, 3: human readable time until reset. +#: src/Admin/ConnectionTester.php:269 +msgid "%1$d of %2$d requests remaining; resets in %3$s." +msgstr "" + +#. translators: 1: number of projects, 2: company ID. +#: src/Admin/ConnectionTester.php:243 +msgid "%1$d project visible in company %2$d." +msgid_plural "%1$d projects visible in company %2$d." +msgstr[0] "" +msgstr[1] "" + +#. translators: %d: number of cached responses. +#: src/Admin/SettingsPage.php:528 +msgid "%d cached response." +msgid_plural "%d cached responses." +msgstr[0] "" +msgstr[1] "" + +#. translators: %d: number of failed checks. +#: src/Admin/ConnectionTester.php:421 +msgid "%d check needs attention." +msgid_plural "%d checks need attention." +msgstr[0] "" +msgstr[1] "" + +#. translators: %s: project name. +#: src/Frontend/Shortcodes/ImageShortcode.php:76 +msgid "%s project image" +msgstr "" + +#: src/Api/Endpoints.php:341 +msgid "A Procore company ID is required for this request." +msgstr "" + +#: src/Api/Endpoints.php:349 +#: src/Api/Endpoints.php:356 +msgid "A Procore project ID is required for this request." +msgstr "" + +#: src/Admin/SettingsPage.php:293 +msgid "A service account keeps working when staff change and is installed by a Procore company administrator from the App Marketplace. A user connection inherits one person's permissions and breaks if their access is revoked." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:234 +msgid "A single allow-listed field from a project record." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:67 +msgid "A table of Procore projects for a company." +msgstr "" + +#: src/Admin/SettingsPage.php:656 +msgid "API host" +msgstr "" + +#: src/Admin/ConnectionTester.php:109 +#: src/Admin/ConnectionTester.php:115 +#: src/Admin/ConnectionTester.php:122 +msgid "Access token" +msgstr "" + +#: src/Support/Format.php:162 +msgid "Active" +msgstr "" + +#: src/Admin/SettingsPage.php:457 +msgid "Added inline after the plugin stylesheet. This survives plugin updates — editing files inside the plugin directory does not." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:112 +#: src/Frontend/Shortcodes/Registrar.php:488 +msgid "Address" +msgstr "" + +#: src/Admin/SettingsPage.php:587 +msgid "Adds a block for each shortcode with a live preview. Shortcodes keep working either way." +msgstr "" + +#: src/Api/Auth/ClientCredentials.php:45 +msgid "Another request is currently authenticating with Procore. Please try again in a moment." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:67 +msgid "Another request is currently refreshing the Procore connection. Please try again in a moment." +msgstr "" + +#: src/Admin/SettingsPage.php:562 +msgid "Anyone" +msgstr "" + +#: src/Admin/ConnectionTester.php:142 +#: src/Admin/ConnectionTester.php:149 +#: src/Api/Endpoints.php:66 +msgid "Authenticated account" +msgstr "" + +#: src/Admin/SettingsPage.php:276 +#: src/Admin/SettingsPage.php:659 +msgid "Authentication" +msgstr "" + +#: src/Admin/SettingsPage.php:228 +msgid "Authorize this site" +msgstr "" + +#: src/Admin/SettingsPage.php:637 +msgid "Available endpoints for [procore_data]" +msgstr "" + +#: src/Admin/SettingsPage.php:470 +msgid "Available templates: collection, record, field, image, map." +msgstr "" + +#: src/Admin/SettingsPage.php:583 +msgid "Block editor" +msgstr "" + +#: src/Admin/SettingsPage.php:145 +msgid "Cache" +msgstr "" + +#: src/Admin/SettingsPage.php:487 +msgid "Cache Procore responses" +msgstr "" + +#: src/Admin/SettingsPage.php:519 +#: src/Admin/SettingsPage.php:676 +msgid "Cached responses" +msgstr "" + +#: src/Api/Endpoints.php:249 +msgid "Change Orders: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:243 +msgid "Change order packages" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:388 +msgid "Change order packages raised on a project." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:387 +msgid "Change orders" +msgstr "" + +#: src/Admin/SettingsPage.php:669 +msgid "Circuit breaker" +msgstr "" + +#: src/Admin/SettingsPage.php:536 +msgid "Clear cache now" +msgstr "" + +#: src/Admin/SettingsPage.php:114 +msgid "Clearing…" +msgstr "" + +#: src/Admin/SettingsPage.php:341 +msgid "Client ID" +msgstr "" + +#: src/Admin/SettingsPage.php:352 +msgid "Client Secret" +msgstr "" + +#: src/Admin/SettingsPage.php:675 +msgid "Closed" +msgstr "" + +#: src/Api/Endpoints.php:76 +msgid "Companies" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:450 +msgid "Companies in the Procore company directory." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:171 +#: src/Frontend/Shortcodes/Registrar.php:454 +msgid "Company" +msgstr "" + +#: src/Api/Endpoints.php:116 +msgid "Company Admin / Project Directory: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:91 +msgid "Company Admin: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:81 +#: src/Api/Endpoints.php:102 +msgid "Company Directory: Read Only" +msgstr "" + +#: src/Admin/ConnectionTester.php:171 +#: src/Admin/ConnectionTester.php:176 +#: src/Admin/ConnectionTester.php:189 +msgid "Company access" +msgstr "" + +#: src/Api/Endpoints.php:96 +msgid "Company directory (vendors)" +msgstr "" + +#: src/Api/Endpoints.php:86 +msgid "Company offices" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:437 +msgid "Complete" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:142 +msgid "Completion date" +msgstr "" + +#: src/Admin/ConnectionTester.php:86 +msgid "Configuration" +msgstr "" + +#: src/Admin/SettingsPage.php:248 +msgid "Connect to Procore" +msgstr "" + +#. translators: 1: account display name, 2: account login. +#: src/Admin/ConnectionTester.php:154 +msgid "Connected as %1$s (%2$s)." +msgstr "" + +#: src/Admin/Notices.php:68 +msgid "Connected to Procore." +msgstr "" + +#: src/Admin/ConnectionTester.php:158 +msgid "Connected." +msgstr "" + +#: src/Admin/SettingsPage.php:143 +msgid "Connection" +msgstr "" + +#: src/Admin/SettingsPage.php:117 +msgid "Copied" +msgstr "" + +#: src/Admin/SettingsPage.php:234 +msgid "Copy" +msgstr "" + +#: src/Admin/SettingsPage.php:660 +msgid "Credential storage" +msgstr "" + +#: src/Admin/SettingsPage.php:334 +msgid "Credentials" +msgstr "" + +#: src/Admin/ConnectionTester.php:109 +msgid "Credentials are incomplete." +msgstr "" + +#: src/Admin/SettingsPage.php:336 +msgid "Credentials are supplied by PROCORE_CONNECT_CLIENT_ID and PROCORE_CONNECT_CLIENT_SECRET in wp-config.php and are not stored in the database. Remove those constants to manage credentials here." +msgstr "" + +#: src/Admin/SettingsPage.php:433 +msgid "Currency symbol" +msgstr "" + +#: src/Api/Environment.php:56 +msgid "Custom (regional or federal zone)" +msgstr "" + +#: src/Admin/SettingsPage.php:323 +msgid "Custom API host" +msgstr "" + +#: src/Admin/SettingsPage.php:449 +msgid "Custom CSS" +msgstr "" + +#: src/Admin/SettingsPage.php:316 +msgid "Custom login host" +msgstr "" + +#: src/Api/Endpoints.php:238 +msgid "Daily Log: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:232 +msgid "Daily construction report logs" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:364 +msgid "Daily construction report logs for a project." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:363 +msgid "Daily logs" +msgstr "" + +#: src/Admin/SettingsPage.php:677 +msgid "Database transients" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:368 +msgid "Date" +msgstr "" + +#: src/Admin/SettingsPage.php:425 +msgid "Date format" +msgstr "" + +#: src/Admin/ConnectionTester.php:210 +#: src/Admin/ConnectionTester.php:227 +#: src/Admin/ConnectionTester.php:233 +#: src/Admin/SettingsPage.php:376 +msgid "Default company" +msgstr "" + +#: src/Admin/SettingsPage.php:395 +msgid "Default project" +msgstr "" + +#: src/Admin/SettingsPage.php:366 +msgid "Delete the stored secret" +msgstr "" + +#: src/Admin/SettingsPage.php:623 +msgid "Depends on the endpoint attribute" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:201 +#: src/Frontend/Shortcodes/Registrar.php:220 +#: src/Frontend/Shortcodes/Registrar.php:314 +msgid "Description" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:108 +msgid "Detail panel for a single Procore project." +msgstr "" + +#: src/Api/Environment.php:54 +msgid "Developer Sandbox" +msgstr "" + +#: src/Admin/SettingsPage.php:591 +msgid "Diagnostics" +msgstr "" + +#: src/Admin/SettingsPage.php:242 +msgid "Disconnect from Procore" +msgstr "" + +#: src/Admin/SettingsPage.php:118 +msgid "Disconnect this site from Procore?" +msgstr "" + +#: src/Admin/Ajax.php:160 +msgid "Disconnected from Procore." +msgstr "" + +#: src/Admin/SettingsPage.php:144 +msgid "Display" +msgstr "" + +#: src/Api/Endpoints.php:154 +msgid "Drawing areas" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:192 +msgid "Drawing areas published for a project." +msgstr "" + +#: src/Api/Endpoints.php:165 +msgid "Drawing revisions" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:191 +msgid "Drawings" +msgstr "" + +#: src/Api/Endpoints.php:160 +#: src/Api/Endpoints.php:171 +msgid "Drawings: Read Only" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:264 +#: src/Frontend/Shortcodes/Registrar.php:294 +#: src/Frontend/Shortcodes/Registrar.php:324 +#: src/Frontend/Shortcodes/Registrar.php:354 +msgid "Due" +msgstr "" + +#: src/Admin/SettingsPage.php:312 +msgid "Each environment needs its own credentials; tokens are not shared between them." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:176 +msgid "Email" +msgstr "" + +#: src/Admin/SettingsPage.php:662 +msgid "Encrypted (AES-256-GCM)" +msgstr "" + +#: src/Admin/Notices.php:77 +msgid "Enter a Client ID and Client Secret before connecting." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:40 +msgid "Enter your Procore Client ID and Client Secret before connecting." +msgstr "" + +#: src/Admin/SettingsPage.php:298 +#: src/Admin/SettingsPage.php:655 +msgid "Environment" +msgstr "" + +#: src/Admin/SettingsPage.php:701 +msgid "Event" +msgstr "" + +#: src/Admin/SettingsPage.php:613 +msgid "Every shortcode below accepts the shared attributes company_id, project_id, limit, page, orderby, order, columns, template, class, title, cache and empty_text. The legacy id attribute is still accepted as an alias for project_id." +msgstr "" + +#: src/Admin/ConnectionTester.php:418 +msgid "Everything checked out." +msgstr "" + +#: src/Admin/SettingsPage.php:554 +msgid "Expose cached Procore data at /wp-json/procore-connect/v1/" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:432 +msgid "Finish" +msgstr "" + +#: src/Admin/SettingsPage.php:370 +msgid "For the strongest protection, define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead of storing it here." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:79 +msgid "ID" +msgstr "" + +#: src/Support/Format.php:162 +msgid "Inactive" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:309 +msgid "Item" +msgstr "" + +#: src/Admin/SettingsPage.php:602 +msgid "Keep settings and cached data when the plugin is deleted" +msgstr "" + +#: src/Admin/SettingsPage.php:595 +msgid "Keeps the last 50 events for the Status tab. Credentials and tokens are redacted before anything is written." +msgstr "" + +#: src/Admin/SettingsPage.php:428 +msgid "Leave blank to use the site date format." +msgstr "" + +#: src/Admin/SettingsPage.php:555 +msgid "Lets themes and JavaScript read Procore data without ever seeing your credentials. Only endpoints published in the registry are reachable." +msgstr "" + +#: src/Admin/SettingsPage.php:700 +msgid "Level" +msgstr "" + +#: src/Admin/SettingsPage.php:444 +msgid "Load the Procore Connect stylesheet" +msgstr "" + +#: src/Admin/SettingsPage.php:115 +msgid "Loading…" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:94 +#: src/Frontend/Shortcodes/Registrar.php:117 +#: src/Frontend/Shortcodes/Registrar.php:459 +#: src/Frontend/Shortcodes/Registrar.php:493 +msgid "Location" +msgstr "" + +#: src/Admin/SettingsPage.php:563 +msgid "Logged-in users" +msgstr "" + +#: src/Admin/SettingsPage.php:657 +msgid "Login host" +msgstr "" + +#: src/Admin/SettingsPage.php:386 +msgid "Look up companies" +msgstr "" + +#: src/Admin/SettingsPage.php:492 +msgid "Minimum cache lifetime" +msgstr "" + +#: src/Api/Environment.php:55 +msgid "Monthly Sandbox" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:161 +#: src/Frontend/Shortcodes/Registrar.php:196 +msgid "Name" +msgstr "" + +#: src/Admin/SettingsPage.php:420 +msgid "Never output email addresses on the front end" +msgstr "" + +#: src/Support/Arr.php:82 +msgid "No" +msgstr "" + +#: src/Admin/ConnectionTester.php:74 +msgid "No Client ID is set." +msgstr "" + +#: src/Admin/ConnectionTester.php:78 +msgid "No Client Secret is set." +msgstr "" + +#: src/Api/Client.php:182 +msgid "No Procore company ID is available. Set a default company in Procore → Connection, or pass company_id." +msgstr "" + +#: src/Admin/ConnectionTester.php:212 +msgid "No default company is set. Choose one below, or pass company_id in every shortcode." +msgstr "" + +#: src/Frontend/Shortcodes/ImageShortcode.php:104 +msgid "No project image is available." +msgstr "" + +#: src/Frontend/Shortcodes/MapShortcode.php:105 +msgid "No project locations are available." +msgstr "" + +#: src/Admin/ConnectionTester.php:298 +msgid "No project was available to test against." +msgstr "" + +#: src/Frontend/Shortcodes/AbstractShortcode.php:392 +msgid "No records were found." +msgstr "" + +#: src/Admin/SettingsPage.php:665 +msgid "No token stored" +msgstr "" + +#: src/Api/Endpoints.php:71 +msgid "None (identity endpoint)" +msgstr "" + +#: src/Frontend/Shortcodes/FieldShortcode.php:181 +msgid "Not available." +msgstr "" + +#: src/Admin/SettingsPage.php:668 +msgid "Not reported yet" +msgstr "" + +#: src/Admin/SettingsPage.php:357 +msgid "Not set" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:373 +msgid "Notes" +msgstr "" + +#: src/Admin/SettingsPage.php:693 +msgid "Nothing recorded. Turn on diagnostics under Tools to start collecting events." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:89 +#: src/Frontend/Shortcodes/Registrar.php:215 +#: src/Frontend/Shortcodes/Registrar.php:249 +#: src/Frontend/Shortcodes/Registrar.php:279 +#: src/Frontend/Shortcodes/Registrar.php:339 +#: src/Frontend/Shortcodes/Registrar.php:393 +msgid "Number" +msgstr "" + +#: src/Admin/SettingsPage.php:677 +msgid "Object cache" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:344 +msgid "Observation" +msgstr "" + +#: src/Api/Endpoints.php:221 +#: src/Frontend/Shortcodes/Registrar.php:333 +msgid "Observations" +msgstr "" + +#: src/Api/Endpoints.php:227 +msgid "Observations: Read Only" +msgstr "" + +#. translators: 1: grant type label, 2: human readable time difference. +#: src/Admin/ConnectionTester.php:126 +msgid "Obtained via %1$s. Valid for another %2$s." +msgstr "" + +#: src/Admin/SettingsPage.php:603 +msgid "Off by default, so deleting the plugin removes its credentials, tokens and cache." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:483 +msgid "Office" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:478 +msgid "Offices" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:479 +msgid "Offices registered against the Procore company." +msgstr "" + +#: src/Admin/SettingsPage.php:328 +msgid "Only used when the environment is set to Custom, for regional and federal zones." +msgstr "" + +#. translators: %s: human readable time difference. +#: src/Admin/SettingsPage.php:672 +msgid "Open — resumes in %s" +msgstr "" + +#: src/Admin/Notices.php:123 +msgid "OpenSSL is not available on this server, so your Procore Client Secret cannot be encrypted in the database. Define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead." +msgstr "" + +#: src/Admin/ConnectionTester.php:82 +msgid "OpenSSL is unavailable, so credentials cannot be encrypted at rest. Define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead." +msgstr "" + +#: src/Admin/SettingsPage.php:403 +msgid "Optional. Used whenever a project-scoped shortcode omits project_id." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:304 +msgid "Outstanding punch list items for a project." +msgstr "" + +#: src/Admin/SettingsPage.php:654 +msgid "PHP" +msgstr "" + +#: src/Admin/SettingsPage.php:677 +msgid "Persistent" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:464 +#: src/Frontend/Shortcodes/Registrar.php:498 +msgid "Phone" +msgstr "" + +#: src/Admin/SettingsPage.php:652 +msgid "Plugin version" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:122 +msgid "Postcode" +msgstr "" + +#: src/Admin/SettingsPage.php:436 +msgid "Prefixed to monetary values such as change order totals." +msgstr "" + +#: src/Admin/SettingsPage.php:417 +msgid "Privacy" +msgstr "" + +#: src/Admin/SettingsPage.php:60 +#: src/Frontend/Shortcodes/ImageShortcode.php:77 +msgid "Procore" +msgstr "" + +#: src/Admin/SettingsPage.php:59 +#: src/Admin/SettingsPage.php:156 +msgid "Procore Connect" +msgstr "" + +#. translators: %s: error message from the Procore API. +#: src/Frontend/Shortcodes/AbstractShortcode.php:359 +msgid "Procore Connect (visible to administrators only): %s" +msgstr "" + +#. translators: %s: settings screen URL. +#: src/Admin/Notices.php:102 +msgid "Procore Connect imported your settings from version 1.x. Version 1.x authenticated against the wrong Procore host, so you need to re-run the connection test before shortcodes will return data." +msgstr "" + +#. translators: 1: environment label, 2: API host. +#: src/Admin/Notices.php:141 +msgid "Procore Connect is pointed at %1$s (%2$s). Front-end shortcodes are showing sandbox data." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:101 +msgid "Procore did not return an access token." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:158 +msgid "Procore did not return an authorization code." +msgstr "" + +#: src/Admin/ConnectionTester.php:259 +msgid "Procore has not reported a rate limit yet." +msgstr "" + +#: src/Api/Client.php:415 +msgid "Procore is rate limiting this site. Cached data will be shown until the limit resets." +msgstr "" + +#: src/Admin/SettingsPage.php:618 +msgid "Procore permission needed" +msgstr "" + +#: src/Admin/SettingsPage.php:506 +msgid "Procore recommends keeping this at or below 2000." +msgstr "" + +#: src/Api/Client.php:394 +msgid "Procore rejected the access token." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:84 +msgid "Procore rejected the authentication request (HTTP %d)." +msgstr "" + +#. translators: %s: human readable time difference. +#: src/Admin/Notices.php:164 +msgid "Procore requests are paused after repeated failures and will resume in %s. Cached data is being served in the meantime." +msgstr "" + +#: src/Api/Client.php:225 +msgid "Procore requests are paused after repeated failures. They will resume automatically." +msgstr "" + +#: src/Api/Client.php:467 +msgid "Procore returned HTTP %d." +msgstr "" + +#: src/Api/Client.php:482 +msgid "Procore returned a response that could not be decoded." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:75 +msgid "Procore returned an unreadable authentication response." +msgstr "" + +#: src/Admin/ConnectionTester.php:178 +msgid "Procore returned no companies. Ask a company administrator to install the app and grant it access." +msgstr "" + +#. translators: %s: shortcode display name, e.g. "RFIs". +#: src/Blocks/Registrar.php:149 +#: src/Blocks/Registrar.php:156 +msgid "Procore: %s" +msgstr "" + +#: src/Api/Environment.php:53 +msgid "Production" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:84 +msgid "Project" +msgstr "" + +#: src/Api/Endpoints.php:126 +msgid "Project Admin: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:136 +#: src/Api/Endpoints.php:146 +msgid "Project Directory: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:121 +msgid "Project detail" +msgstr "" + +#: src/Frontend/Shortcodes/AbstractShortcode.php:366 +#: src/Rest/Controller.php:209 +msgid "Project information is temporarily unavailable." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:507 +msgid "Project locations" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:127 +msgid "Project number" +msgstr "" + +#: src/Api/Endpoints.php:131 +#: src/Frontend/Shortcodes/Registrar.php:156 +msgid "Project team" +msgstr "" + +#: src/Api/Endpoints.php:141 +msgid "Project vendors" +msgstr "" + +#: src/Api/Endpoints.php:110 +#: src/Frontend/Shortcodes/Registrar.php:66 +msgid "Projects" +msgstr "" + +#. translators: %d: company ID. +#: src/Admin/ConnectionTester.php:238 +msgid "Projects are readable for company %d." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:508 +msgid "Projects that have coordinates, as an accessible location list with geo microdata." +msgstr "" + +#: src/Api/Endpoints.php:216 +msgid "Punch List: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:210 +#: src/Frontend/Shortcodes/Registrar.php:303 +msgid "Punch list" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:334 +msgid "Quality and safety observations recorded on a project." +msgstr "" + +#: src/Admin/SettingsPage.php:559 +msgid "REST access" +msgstr "" + +#: src/Admin/SettingsPage.php:551 +msgid "REST proxy" +msgstr "" + +#: src/Api/Endpoints.php:190 +#: src/Frontend/Shortcodes/Registrar.php:243 +msgid "RFIs" +msgstr "" + +#: src/Api/Endpoints.php:195 +msgid "RFIs: Read Only" +msgstr "" + +#: src/Admin/ConnectionTester.php:259 +#: src/Admin/ConnectionTester.php:265 +#: src/Admin/SettingsPage.php:666 +msgid "Rate limit" +msgstr "" + +#: src/Admin/ConnectionTester.php:330 +msgid "Readable." +msgstr "" + +#. translators: 1: environment label, 2: API host. +#: src/Admin/ConnectionTester.php:91 +msgid "Ready. Environment: %1$s (%2$s)." +msgstr "" + +#: src/Admin/SettingsPage.php:690 +msgid "Recent diagnostics" +msgstr "" + +#: src/Admin/SettingsPage.php:421 +msgid "Recommended. Procore project directories contain personal data; publishing them exposes staff and subcontractors to harvesting. When this is off, addresses are still obfuscated and each shortcode must additionally opt in with show_email=\"true\"." +msgstr "" + +#: src/Admin/SettingsPage.php:594 +msgid "Record API diagnostics" +msgstr "" + +#: src/Admin/SettingsPage.php:501 +msgid "Records per request" +msgstr "" + +#: src/Admin/SettingsPage.php:658 +msgid "Redirect URI" +msgstr "" + +#: src/Admin/SettingsPage.php:586 +msgid "Register Procore Connect blocks" +msgstr "" + +#: src/Admin/SettingsPage.php:229 +msgid "Register this exact redirect URI in the Procore Developer Portal before connecting:" +msgstr "" + +#. translators: %d: number of cache entries removed. +#: src/Admin/Ajax.php:77 +#: src/Cli/Commands.php:101 +msgid "Removed %d cached response." +msgid_plural "Removed %d cached responses." +msgstr[0] "" +msgstr[1] "" + +#: src/Frontend/Shortcodes/Registrar.php:517 +msgid "Render any endpoint published in the Procore endpoint registry." +msgstr "" + +#: src/Admin/SettingsPage.php:510 +msgid "Request timeout" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:244 +msgid "Requests for information raised on a project." +msgstr "" + +#: src/Admin/SettingsPage.php:484 +msgid "Response cache" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:225 +msgid "Revision" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:166 +msgid "Role" +msgstr "" + +#: src/Admin/SettingsPage.php:113 +msgid "Running checks…" +msgstr "" + +#: src/Admin/SettingsPage.php:257 +msgid "Runs through authentication, company access and every endpoint the shortcodes use, so you can see exactly which Procore tool permissions your credentials have." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:417 +msgid "Schedule" +msgstr "" + +#: src/Api/Endpoints.php:254 +msgid "Schedule tasks" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:418 +msgid "Schedule tasks and milestones for a project." +msgstr "" + +#: src/Api/Endpoints.php:259 +msgid "Schedule: Read Only" +msgstr "" + +#: src/Admin/SettingsPage.php:515 +msgid "Seconds to wait for Procore before giving up." +msgstr "" + +#: src/Admin/SettingsPage.php:497 +msgid "Seconds. Shortcodes may request a longer lifetime but never a shorter one, so a single page cannot be configured into a rate-limit breach." +msgstr "" + +#: src/Api/Auth/ClientCredentials.php:94 +msgid "Service Account (Client Credentials)" +msgstr "" + +#: src/Admin/SettingsPage.php:279 +msgid "Service Account (Client Credentials) — recommended" +msgstr "" + +#: src/Plugin.php:158 +msgid "Settings" +msgstr "" + +#: src/Admin/SettingsPage.php:616 +msgid "Shortcode" +msgstr "" + +#: src/Admin/SettingsPage.php:146 +msgid "Shortcodes" +msgstr "" + +#: src/Admin/SettingsPage.php:119 +msgid "Skipped" +msgstr "" + +#: src/Api/Endpoints.php:176 +msgid "Specification sections" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:211 +msgid "Specification sections published for a project." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:210 +msgid "Specifications" +msgstr "" + +#: src/Api/Endpoints.php:182 +msgid "Specifications: Read Only" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:132 +msgid "Stage" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:427 +msgid "Start" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:137 +msgid "Start date" +msgstr "" + +#: src/Admin/SettingsPage.php:147 +#: src/Frontend/Shortcodes/Registrar.php:99 +#: src/Frontend/Shortcodes/Registrar.php:147 +#: src/Frontend/Shortcodes/Registrar.php:259 +#: src/Frontend/Shortcodes/Registrar.php:289 +#: src/Frontend/Shortcodes/Registrar.php:319 +#: src/Frontend/Shortcodes/Registrar.php:349 +#: src/Frontend/Shortcodes/Registrar.php:378 +#: src/Frontend/Shortcodes/Registrar.php:403 +msgid "Status" +msgstr "" + +#: src/Admin/SettingsPage.php:488 +msgid "Strongly recommended. Procore enforces both an hourly and a ten-second rate limit; an uncached page will exhaust the quota quickly. When caching is on, a Procore outage serves the last good response instead of an error." +msgstr "" + +#: src/Admin/SettingsPage.php:441 +msgid "Stylesheet" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:254 +msgid "Subject" +msgstr "" + +#: src/Api/Endpoints.php:200 +#: src/Frontend/Shortcodes/Registrar.php:273 +msgid "Submittals" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:274 +msgid "Submittals tracked on a project." +msgstr "" + +#: src/Api/Endpoints.php:205 +msgid "Submittals: Read Only" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:422 +msgid "Task" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:157 +msgid "Team members assigned to a project. Email addresses are hidden unless explicitly enabled." +msgstr "" + +#: src/Admin/SettingsPage.php:462 +msgid "Template overrides" +msgstr "" + +#: src/Admin/SettingsPage.php:260 +msgid "Test connection" +msgstr "" + +#: src/Rest/Controller.php:177 +msgid "The Procore Connect REST proxy is disabled." +msgstr "" + +#: src/Admin/Notices.php:76 +msgid "The Procore authorization request was declined." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:149 +msgid "The Procore authorization response could not be verified. Please start the connection again." +msgstr "" + +#: src/Admin/Notices.php:78 +msgid "The Procore connection could not be completed." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:101 +msgid "The Procore connection has expired. Open Procore → Connection and reconnect." +msgstr "" + +#: src/Api/Client.php:425 +msgid "The Procore request could not be completed." +msgstr "" + +#. translators: 1: requested endpoint slug, 2: comma-separated list of endpoint slugs. +#: src/Frontend/Shortcodes/DataShortcode.php:66 +msgid "The endpoint \"%1$s\" is not available to shortcodes. Available endpoints: %2$s" +msgstr "" + +#. translators: %s: comma-separated list of endpoint slugs. +#: src/Frontend/Shortcodes/DataShortcode.php:55 +msgid "The endpoint attribute is required. Available endpoints: %s" +msgstr "" + +#. translators: %s: requested field name. +#: src/Frontend/Shortcodes/FieldShortcode.php:112 +msgid "The field \"%s\" is not available for display. Add it with the procore_connect_allowed_project_fields filter." +msgstr "" + +#: src/Frontend/Shortcodes/FieldShortcode.php:103 +msgid "The field attribute is required." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:185 +msgid "The logo or featured image for a project." +msgstr "" + +#: src/Admin/SettingsPage.php:116 +msgid "The request failed. Check your connection and try again." +msgstr "" + +#: src/Admin/SettingsPage.php:360 +msgid "The stored secret is never shown. Leave this blank to keep the current value." +msgstr "" + +#: src/Admin/SettingsPage.php:445 +msgid "The stylesheet loads only on pages that actually contain Procore Connect output. Turn it off entirely if your theme styles the markup itself." +msgstr "" + +#: src/Frontend/Shortcodes/AbstractShortcode.php:216 +msgid "This shortcode is not bound to a known Procore endpoint." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:81 +msgid "This site is not connected to Procore." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:56 +msgid "This site is not connected to Procore. Open Procore → Connection and choose \"Connect to Procore\"." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:284 +#: src/Frontend/Shortcodes/Registrar.php:398 +msgid "Title" +msgstr "" + +#: src/Admin/SettingsPage.php:464 +msgid "To change the markup, copy a file from the plugin's templates directory into your theme:" +msgstr "" + +#: src/Admin/SettingsPage.php:663 +msgid "Token expires" +msgstr "" + +#: src/Admin/SettingsPage.php:148 +msgid "Tools" +msgstr "" + +#: src/Admin/SettingsPage.php:662 +msgid "Unencrypted — OpenSSL unavailable" +msgstr "" + +#: src/Admin/SettingsPage.php:599 +msgid "Uninstall" +msgstr "" + +#: src/Api/Client.php:172 +#: src/Api/Endpoints.php:333 +msgid "Unknown Procore endpoint: %s" +msgstr "" + +#: src/Admin/SettingsPage.php:390 +msgid "Used whenever a shortcode omits company_id. Also visible in your Procore URL: app.procore.com/companies/XXXX/." +msgstr "" + +#: src/Admin/SettingsPage.php:280 +#: src/Api/Auth/AuthorizationCode.php:213 +msgid "User Account (Authorization Code)" +msgstr "" + +#: src/Admin/SettingsPage.php:564 +msgid "Users who can edit posts" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:408 +msgid "Value" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:449 +msgid "Vendors" +msgstr "" + +#: src/Admin/SettingsPage.php:256 +msgid "Verify the connection" +msgstr "" + +#. translators: %s: project name. +#: templates/map.php:56 +msgid "View %s on a map" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:469 +msgid "Website" +msgstr "" + +#: src/Admin/SettingsPage.php:617 +msgid "What it shows" +msgstr "" + +#: src/Admin/SettingsPage.php:699 +msgid "When" +msgstr "" + +#: src/Admin/SettingsPage.php:653 +msgid "WordPress" +msgstr "" + +#: src/Support/Arr.php:82 +msgid "Yes" +msgstr "" + +#: src/Admin/OAuthController.php:77 +msgid "You do not have permission to complete this connection." +msgstr "" + +#: src/Admin/OAuthController.php:48 +msgid "You do not have permission to connect this site to Procore." +msgstr "" + +#: src/Admin/Ajax.php:170 +msgid "You do not have permission to do that." +msgstr "" + +#: src/Admin/SettingsPage.php:139 +msgid "You do not have permission to view this page." +msgstr "" + +#: src/Admin/ConnectionTester.php:156 +msgid "no login" +msgstr "" + +#: src/Admin/ConnectionTester.php:155 +msgid "service account" +msgstr "" + +#: src/Admin/SettingsPage.php:661 +msgid "wp-config.php constants" +msgstr "" diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..0aed712 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,90 @@ + + + Coding standards for the Procore Connect plugin. + + . + + /vendor/* + /node_modules/* + /build/* + /docs/* + /tests/fixtures/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /src/* + /tests/* + + + + + /templates/* + + + + /tests/* + + + + + /src/Cli/* + /tests/* + + + + + /tests/* + + + /tests/* + + + /tests/wp-shims.php + + + /tests/wp-shims.php + + + /tests/wp-shims.php + + + /tests/wp-shims.php + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dc3cd06 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests/unit + + + + + src + + + diff --git a/procore-connect.php b/procore-connect.php new file mode 100644 index 0000000..6dd6149 --- /dev/null +++ b/procore-connect.php @@ -0,0 +1,62 @@ +authorise(); + + wp_send_json_success( ( new ConnectionTester() )->run() ); + } + + /** + * Purge cached Procore responses. + * + * @return void + */ + public function clear_cache(): void { + $this->authorise(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorise() calls check_ajax_referer() immediately above. + $group = isset( $_POST['group'] ) ? sanitize_key( wp_unslash( $_POST['group'] ) ) : ''; + $removed = Cache::flush( $group ); + + Client::reset_circuit(); + + wp_send_json_success( + array( + 'removed' => $removed, + 'message' => sprintf( + /* translators: %d: number of cache entries removed. */ + _n( 'Removed %d cached response.', 'Removed %d cached responses.', $removed, 'procore-connect' ), + $removed + ), + 'stats' => Cache::stats(), + ) + ); + } + + /** + * List the companies available to the stored credentials. + * + * @return void + */ + public function companies(): void { + $this->authorise(); + + $companies = ( new ConnectionTester() )->companies(); + + if ( is_wp_error( $companies ) ) { + wp_send_json_error( array( 'message' => $companies->get_error_message() ) ); + } + + $options = array(); + + foreach ( (array) $companies as $company ) { + $options[] = array( + 'id' => absint( Arr::get( $company, 'id', 0 ) ), + 'name' => Arr::str( $company, 'name' ), + ); + } + + wp_send_json_success( array( 'companies' => $options ) ); + } + + /** + * List projects for a company, used to populate block and settings pickers. + * + * @return void + */ + public function projects(): void { + $this->authorise(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorise() calls check_ajax_referer() immediately above. + $company = isset( $_POST['company_id'] ) ? absint( wp_unslash( $_POST['company_id'] ) ) : Settings::default_company_id(); + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'all' => true, + ) + ); + + if ( is_wp_error( $projects ) ) { + wp_send_json_error( array( 'message' => $projects->get_error_message() ) ); + } + + $options = array(); + + foreach ( (array) $projects as $project ) { + $options[] = array( + 'id' => absint( Arr::get( $project, 'id', 0 ) ), + 'name' => Arr::str( $project, 'name' ), + 'active' => (bool) Arr::get( $project, 'active', true ), + ); + } + + wp_send_json_success( array( 'projects' => $options ) ); + } + + /** + * Discard the stored tokens. + * + * @return void + */ + public function disconnect(): void { + $this->authorise(); + + TokenStore::clear(); + Cache::flush(); + Client::reset_circuit(); + Logger::info( 'Procore connection reset from the admin screen.' ); + + wp_send_json_success( array( 'message' => __( 'Disconnected from Procore.', 'procore-connect' ) ) ); + } + + /** + * Verify the nonce and capability, terminating the request on failure. + * + * @return void + */ + private function authorise(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'You do not have permission to do that.', 'procore-connect' ) ), 403 ); + } + + check_ajax_referer( self::NONCE, 'nonce' ); + } +} diff --git a/src/Admin/ConnectionTester.php b/src/Admin/ConnectionTester.php new file mode 100644 index 0000000..875dabf --- /dev/null +++ b/src/Admin/ConnectionTester.php @@ -0,0 +1,426 @@ + Report with `steps`, `probes` and `summary`. + */ + public function run(): array { + $steps = array(); + + $steps[] = $this->check_prerequisites(); + + $token = $this->check_token(); + $steps[] = $token; + + if ( ! $token['ok'] ) { + return $this->report( $steps, array() ); + } + + $identity = $this->check_identity(); + $steps[] = $identity; + + $companies = $this->check_companies(); + $steps[] = $companies; + + $company = $this->check_company_access(); + $steps[] = $company; + + $steps[] = $this->check_rate_limit(); + + $probes = $company['ok'] ? $this->probe_endpoints() : array(); + + return $this->report( $steps, $probes ); + } + + /** + * Confirm the plugin has what it needs before contacting Procore. + * + * @return array Step result. + */ + private function check_prerequisites(): array { + $issues = array(); + + if ( '' === Settings::client_id() ) { + $issues[] = __( 'No Client ID is set.', 'procore-connect' ); + } + + if ( '' === Settings::client_secret() ) { + $issues[] = __( 'No Client Secret is set.', 'procore-connect' ); + } + + if ( ! Encryption::is_strong() ) { + $issues[] = __( 'OpenSSL is unavailable, so credentials cannot be encrypted at rest. Define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead.', 'procore-connect' ); + } + + return $this->step( + __( 'Configuration', 'procore-connect' ), + empty( $issues ), + empty( $issues ) + ? sprintf( + /* translators: 1: environment label, 2: API host. */ + __( 'Ready. Environment: %1$s (%2$s).', 'procore-connect' ), + Environment::choices()[ Environment::current() ], + Environment::api_host() + ) + : implode( ' ', $issues ) + ); + } + + /** + * Obtain an access token. + * + * @return array Step result. + */ + private function check_token(): array { + $client = Client::instance(); + $auth = $client->auth(); + + if ( ! $auth->is_configured() ) { + return $this->step( __( 'Access token', 'procore-connect' ), false, __( 'Credentials are incomplete.', 'procore-connect' ) ); + } + + $token = $auth->access_token(); + + if ( is_wp_error( $token ) ) { + return $this->step( __( 'Access token', 'procore-connect' ), false, $token->get_error_message() ); + } + + $stored = TokenStore::all(); + $expires = (int) $stored['expires_at']; + + return $this->step( + __( 'Access token', 'procore-connect' ), + true, + sprintf( + /* translators: 1: grant type label, 2: human readable time difference. */ + __( 'Obtained via %1$s. Valid for another %2$s.', 'procore-connect' ), + $auth->label(), + human_time_diff( time(), max( $expires, time() ) ) + ) + ); + } + + /** + * Read the authenticated account. + * + * @return array Step result. + */ + private function check_identity(): array { + $me = Client::instance()->fetch( 'me', array(), array( 'bypass_cache' => true ) ); + + if ( is_wp_error( $me ) ) { + return $this->step( __( 'Authenticated account', 'procore-connect' ), false, $me->get_error_message() ); + } + + $name = Arr::str( $me, 'name' ); + $login = Arr::str( $me, 'login' ); + + return $this->step( + __( 'Authenticated account', 'procore-connect' ), + true, + '' !== $name || '' !== $login + ? sprintf( + /* translators: 1: account display name, 2: account login. */ + __( 'Connected as %1$s (%2$s).', 'procore-connect' ), + '' !== $name ? $name : __( 'service account', 'procore-connect' ), + '' !== $login ? $login : __( 'no login', 'procore-connect' ) + ) + : __( 'Connected.', 'procore-connect' ) + ); + } + + /** + * List the companies the credentials can reach. + * + * @return array Step result. + */ + private function check_companies(): array { + $companies = $this->companies(); + + if ( is_wp_error( $companies ) ) { + return $this->step( __( 'Company access', 'procore-connect' ), false, $companies->get_error_message() ); + } + + if ( empty( $companies ) ) { + return $this->step( + __( 'Company access', 'procore-connect' ), + false, + __( 'Procore returned no companies. Ask a company administrator to install the app and grant it access.', 'procore-connect' ) + ); + } + + $names = array(); + + foreach ( array_slice( $companies, 0, 10 ) as $company ) { + $names[] = sprintf( '%s (%d)', Arr::str( $company, 'name' ), absint( Arr::get( $company, 'id', 0 ) ) ); + } + + return $this->step( + __( 'Company access', 'procore-connect' ), + true, + sprintf( + /* translators: 1: number of companies, 2: comma-separated company names. */ + _n( '%1$d company available: %2$s', '%1$d companies available: %2$s', count( $companies ), 'procore-connect' ), + count( $companies ), + implode( ', ', $names ) + ) + ); + } + + /** + * Confirm the configured default company is usable. + * + * @return array Step result. + */ + private function check_company_access(): array { + $company = Settings::default_company_id(); + + if ( $company <= 0 ) { + return $this->step( + __( 'Default company', 'procore-connect' ), + false, + __( 'No default company is set. Choose one below, or pass company_id in every shortcode.', 'procore-connect' ) + ); + } + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'bypass_cache' => true, + 'per_page' => 1, + ) + ); + + if ( is_wp_error( $projects ) ) { + return $this->step( __( 'Default company', 'procore-connect' ), false, $projects->get_error_message() ); + } + + $total = Client::instance()->meta()['total']; + + return $this->step( + __( 'Default company', 'procore-connect' ), + true, + null === $total + ? sprintf( + /* translators: %d: company ID. */ + __( 'Projects are readable for company %d.', 'procore-connect' ), + $company + ) + : sprintf( + /* translators: 1: number of projects, 2: company ID. */ + _n( '%1$d project visible in company %2$d.', '%1$d projects visible in company %2$d.', (int) $total, 'procore-connect' ), + (int) $total, + $company + ) + ); + } + + /** + * Report the remaining rate-limit headroom. + * + * @return array Step result. + */ + private function check_rate_limit(): array { + $limit = Client::rate_limit(); + + if ( 0 === $limit['limit'] ) { + return $this->step( __( 'Rate limit', 'procore-connect' ), true, __( 'Procore has not reported a rate limit yet.', 'procore-connect' ) ); + } + + $healthy = $limit['limit'] < 1 || ( $limit['remaining'] / max( 1, $limit['limit'] ) ) > 0.1; + + return $this->step( + __( 'Rate limit', 'procore-connect' ), + $healthy, + sprintf( + /* translators: 1: remaining requests, 2: total allowed requests, 3: human readable time until reset. */ + __( '%1$d of %2$d requests remaining; resets in %3$s.', 'procore-connect' ), + $limit['remaining'], + $limit['limit'], + human_time_diff( time(), max( $limit['reset'], time() ) ) + ) + ); + } + + /** + * Probe every registered endpoint to reveal which tool permissions apply. + * + * @return array> Probe results. + */ + private function probe_endpoints(): array { + $company = Settings::default_company_id(); + $project = $this->sample_project_id( $company ); + $results = array(); + + foreach ( Endpoints::all() as $slug => $definition ) { + if ( in_array( $slug, array( 'me', 'companies' ), true ) ) { + continue; + } + + if ( Endpoints::SCOPE_PROJECT === $definition['scope'] && $project <= 0 ) { + $results[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'permission' => (string) $definition['permission'], + 'status' => 'skipped', + 'message' => __( 'No project was available to test against.', 'procore-connect' ), + ); + continue; + } + + $response = Client::instance()->fetch( + $slug, + array(), + array( + 'company_id' => $company, + 'project_id' => $project, + 'per_page' => 1, + 'bypass_cache' => true, + ) + ); + + if ( is_wp_error( $response ) ) { + $results[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'permission' => (string) $definition['permission'], + 'status' => 'failed', + 'message' => $response->get_error_message(), + ); + continue; + } + + $results[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'permission' => (string) $definition['permission'], + 'status' => 'ok', + 'message' => __( 'Readable.', 'procore-connect' ), + ); + }//end foreach + + return $results; + } + + /** + * Find a project identifier to probe project-scoped endpoints against. + * + * @param int $company Company identifier. + * @return int Project identifier, or zero when none is available. + */ + private function sample_project_id( int $company ): int { + $configured = Settings::default_project_id(); + + if ( $configured > 0 ) { + return $configured; + } + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'per_page' => 1, + ) + ); + + if ( is_wp_error( $projects ) || ! is_array( $projects ) || empty( $projects ) ) { + return 0; + } + + return absint( Arr::get( reset( $projects ), 'id', 0 ) ); + } + + /** + * Retrieve the companies available to the current credentials. + * + * @return array|\WP_Error Companies, or an error. + */ + public function companies() { + $companies = Client::instance()->fetch( 'companies', array(), array( 'bypass_cache' => true ) ); + + if ( is_wp_error( $companies ) ) { + return $companies; + } + + return is_array( $companies ) ? $companies : array(); + } + + /** + * Build a step result. + * + * @param string $label Step name. + * @param bool $ok Whether the step succeeded. + * @param string $message Detail message. + * @return array Step result. + */ + private function step( string $label, bool $ok, string $message ): array { + return array( + 'label' => $label, + 'ok' => $ok, + 'message' => $message, + ); + } + + /** + * Assemble the final report. + * + * @param array> $steps Step results. + * @param array> $probes Endpoint probe results. + * @return array Report. + */ + private function report( array $steps, array $probes ): array { + $failed = 0; + + foreach ( $steps as $step ) { + if ( ! $step['ok'] ) { + ++$failed; + } + } + + return array( + 'steps' => $steps, + 'probes' => $probes, + 'ok' => 0 === $failed, + 'summary' => 0 === $failed + ? __( 'Everything checked out.', 'procore-connect' ) + : sprintf( + /* translators: %d: number of failed checks. */ + _n( '%d check needs attention.', '%d checks need attention.', $failed, 'procore-connect' ), + $failed + ), + ); + } +} diff --git a/src/Admin/Notices.php b/src/Admin/Notices.php new file mode 100644 index 0000000..e2b3180 --- /dev/null +++ b/src/Admin/Notices.php @@ -0,0 +1,198 @@ +oauth_result(); + $this->migration_notice(); + + if ( ! $this->on_plugin_screen() ) { + return; + } + + $this->encryption_notice(); + $this->environment_notice(); + $this->circuit_notice(); + } + + /** + * Report the outcome of an Authorization Code round trip. + * + * @return void + */ + private function oauth_result(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag set by our own redirect. + $status = isset( $_GET['procore_connect_oauth'] ) ? sanitize_key( wp_unslash( $_GET['procore_connect_oauth'] ) ) : ''; + + if ( '' === $status ) { + return; + } + + if ( 'connected' === $status ) { + $this->notice( 'success', __( 'Connected to Procore.', 'procore-connect' ) ); + + return; + } + + $stored_error = (string) get_transient( 'procore_connect_oauth_error' ); + + $messages = array( + 'denied' => __( 'The Procore authorization request was declined.', 'procore-connect' ), + 'missing_credentials' => __( 'Enter a Client ID and Client Secret before connecting.', 'procore-connect' ), + 'failed' => '' !== $stored_error ? $stored_error : __( 'The Procore connection could not be completed.', 'procore-connect' ), + ); + + delete_transient( 'procore_connect_oauth_error' ); + + if ( isset( $messages[ $status ] ) ) { + $this->notice( 'error', $messages[ $status ] ); + } + } + + /** + * Point administrators at the settings screen after a 1.x upgrade. + * + * @return void + */ + private function migration_notice(): void { + if ( '1.x' !== get_option( 'procore_connect_migrated_from' ) ) { + return; + } + + $this->notice( + 'warning', + sprintf( + /* translators: %s: settings screen URL. */ + __( 'Procore Connect imported your settings from version 1.x. Version 1.x authenticated against the wrong Procore host, so you need to re-run the connection test before shortcodes will return data.', 'procore-connect' ), + esc_url( admin_url( 'admin.php?page=procore-connect' ) ) + ), + true + ); + + delete_option( 'procore_connect_migrated_from' ); + } + + /** + * Warn when credentials cannot be encrypted at rest. + * + * @return void + */ + private function encryption_notice(): void { + if ( Encryption::is_strong() || Settings::credentials_are_constants() ) { + return; + } + + $this->notice( + 'warning', + __( 'OpenSSL is not available on this server, so your Procore Client Secret cannot be encrypted in the database. Define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead.', 'procore-connect' ) + ); + } + + /** + * Make a non-production environment obvious. + * + * @return void + */ + private function environment_notice(): void { + if ( Environment::is_production() ) { + return; + } + + $this->notice( + 'info', + sprintf( + /* translators: 1: environment label, 2: API host. */ + __( 'Procore Connect is pointed at %1$s (%2$s). Front-end shortcodes are showing sandbox data.', 'procore-connect' ), + Environment::choices()[ Environment::current() ], + Environment::api_host() + ) + ); + } + + /** + * Report an open circuit breaker. + * + * @return void + */ + private function circuit_notice(): void { + $state = Client::circuit_state(); + + if ( $state['open_until'] <= time() ) { + return; + } + + $this->notice( + 'error', + sprintf( + /* translators: %s: human readable time difference. */ + __( 'Procore requests are paused after repeated failures and will resume in %s. Cached data is being served in the meantime.', 'procore-connect' ), + human_time_diff( time(), $state['open_until'] ) + ) + ); + } + + /** + * Whether the current screen belongs to this plugin. + * + * @return bool True on a Procore Connect screen. + */ + private function on_plugin_screen(): bool { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + return $screen instanceof \WP_Screen && false !== strpos( (string) $screen->id, 'procore-connect' ); + } + + /** + * Print a notice. + * + * @param string $type Notice type: success, warning, error or info. + * @param string $message Message text. + * @param bool $allow_html Whether the message contains a permitted link. + * @return void + */ + private function notice( string $type, string $message, bool $allow_html = false ): void { + printf( + '

%2$s

', + esc_attr( $type ), + $allow_html + ? wp_kses( $message, array( 'a' => array( 'href' => array() ) ) ) + : esc_html( $message ) + ); + } +} diff --git a/src/Admin/OAuthController.php b/src/Admin/OAuthController.php new file mode 100644 index 0000000..702bf0f --- /dev/null +++ b/src/Admin/OAuthController.php @@ -0,0 +1,117 @@ + 403 ) ); + } + + check_admin_referer( self::NONCE ); + + $auth = new AuthorizationCode(); + + if ( ! $auth->is_configured() ) { + $this->redirect_back( 'missing_credentials' ); + } + + /* + * wp_safe_redirect() is deliberately not used: the destination is + * Procore's own authorization screen on an external host, which the + * safe-redirect allow-list would block. The URL is built entirely from + * the configured environment, never from request input. + */ + // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- External OAuth provider, URL built from trusted settings. + wp_redirect( $auth->authorization_url() ); + exit; + } + + /** + * Handle Procore's redirect back to this site. + * + * @return void + */ + public function callback(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to complete this connection.', 'procore-connect' ), '', array( 'response' => 403 ) ); + } + + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Verified via the one-time OAuth state parameter below. + if ( isset( $_GET['error'] ) ) { + $this->redirect_back( 'denied' ); + } + + $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; + $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $result = ( new AuthorizationCode() )->exchange_code( $code, $state ); + + if ( is_wp_error( $result ) ) { + set_transient( 'procore_connect_oauth_error', $result->get_error_message(), MINUTE_IN_SECONDS * 5 ); + $this->redirect_back( 'failed' ); + } + + $this->redirect_back( 'connected' ); + } + + /** + * Return to the settings screen carrying a status flag. + * + * @param string $status Status slug appended to the URL. + * @return void + */ + private function redirect_back( string $status ): void { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'procore-connect', + 'procore_connect_oauth' => $status, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } +} diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php new file mode 100644 index 0000000..d5a0f1b --- /dev/null +++ b/src/Admin/Settings.php @@ -0,0 +1,309 @@ +|null + */ + private static $cache = null; + + /** + * Default values for every setting. + * + * @return array Defaults. + */ + public static function defaults(): array { + return array( + 'auth_mode' => 'client_credentials', + 'environment' => 'production', + 'custom_login_url' => '', + 'custom_api_url' => '', + 'client_id' => '', + 'client_secret' => '', + 'default_company_id' => 0, + 'default_project_id' => 0, + + 'enable_cache' => true, + 'cache_floor' => 300, + 'cache_multiplier' => 100, + 'per_page' => 100, + 'request_timeout' => 15, + + 'suppress_emails' => true, + 'date_format' => '', + 'currency_symbol' => '$', + 'custom_css' => '', + 'load_styles' => true, + + 'enable_rest' => false, + 'rest_access' => 'logged_in', + 'enable_blocks' => true, + 'enable_logging' => false, + 'keep_data' => false, + ); + } + + /** + * All settings, with defaults applied. + * + * @return array Settings. + */ + public static function all(): array { + if ( null !== self::$cache ) { + return self::$cache; + } + + $stored = get_option( self::OPTION, array() ); + + self::$cache = wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() ); + + return self::$cache; + } + + /** + * Read a single setting. + * + * @param string $key Setting key. + * @param mixed $default_value Value returned when the key is unknown. + * @return mixed Setting value. + */ + public static function get( string $key, $default_value = null ) { + $all = self::all(); + + return array_key_exists( $key, $all ) ? $all[ $key ] : $default_value; + } + + /** + * Write a single setting. + * + * @param string $key Setting key. + * @param mixed $value New value. + * @return void + */ + public static function set( string $key, $value ): void { + $all = self::all(); + $all[ $key ] = $value; + + update_option( self::OPTION, $all, false ); + self::flush(); + } + + /** + * Discard the memoised settings. + * + * @return void + */ + public static function flush(): void { + self::$cache = null; + } + + /** + * The effective Procore client ID. + * + * @return string Client ID. + */ + public static function client_id(): string { + if ( defined( 'PROCORE_CONNECT_CLIENT_ID' ) && '' !== (string) constant( 'PROCORE_CONNECT_CLIENT_ID' ) ) { + return (string) constant( 'PROCORE_CONNECT_CLIENT_ID' ); + } + + return (string) self::get( 'client_id', '' ); + } + + /** + * The effective Procore client secret, decrypted. + * + * @return string Client secret. + */ + public static function client_secret(): string { + if ( defined( 'PROCORE_CONNECT_CLIENT_SECRET' ) && '' !== (string) constant( 'PROCORE_CONNECT_CLIENT_SECRET' ) ) { + return (string) constant( 'PROCORE_CONNECT_CLIENT_SECRET' ); + } + + return Encryption::decrypt( (string) self::get( 'client_secret', '' ) ); + } + + /** + * Whether credentials come from `wp-config.php` constants. + * + * @return bool True when constants are in use. + */ + public static function credentials_are_constants(): bool { + return defined( 'PROCORE_CONNECT_CLIENT_SECRET' ) && '' !== (string) constant( 'PROCORE_CONNECT_CLIENT_SECRET' ); + } + + /** + * The default Procore company identifier. + * + * @return int Company ID, or zero when unset. + */ + public static function default_company_id(): int { + if ( defined( 'PROCORE_CONNECT_COMPANY_ID' ) ) { + return absint( constant( 'PROCORE_CONNECT_COMPANY_ID' ) ); + } + + return absint( self::get( 'default_company_id', 0 ) ); + } + + /** + * The default Procore project identifier. + * + * @return int Project ID, or zero when unset. + */ + public static function default_project_id(): int { + return absint( self::get( 'default_project_id', 0 ) ); + } + + /** + * Sanitize the settings array submitted from the options form. + * + * Registered as the `sanitize_callback` for `register_setting()`, which + * ProcoreWP 1.x omitted entirely. + * + * @param mixed $input Raw submitted value. + * @return array Sanitized settings. + */ + public static function sanitize( $input ): array { + $existing = self::all(); + $input = is_array( $input ) ? $input : array(); + $clean = $existing; + + $clean['auth_mode'] = in_array( $input['auth_mode'] ?? '', array( 'client_credentials', 'authorization_code' ), true ) + ? $input['auth_mode'] + : 'client_credentials'; + + $clean['environment'] = array_key_exists( $input['environment'] ?? '', Environment::choices() ) + ? $input['environment'] + : 'production'; + + $clean['custom_login_url'] = esc_url_raw( trim( (string) ( $input['custom_login_url'] ?? '' ) ), array( 'https' ) ); + $clean['custom_api_url'] = esc_url_raw( trim( (string) ( $input['custom_api_url'] ?? '' ) ), array( 'https' ) ); + + $clean['client_id'] = sanitize_text_field( (string) ( $input['client_id'] ?? '' ) ); + + // An empty secret field means "leave the stored secret alone"; the form + // renders a masked placeholder rather than the real value. + $submitted_secret = trim( (string) ( $input['client_secret'] ?? '' ) ); + + if ( '' !== $submitted_secret ) { + $clean['client_secret'] = Encryption::encrypt( $submitted_secret ); + } + + if ( ! empty( $input['clear_client_secret'] ) ) { + $clean['client_secret'] = ''; + } + + $clean['default_company_id'] = absint( $input['default_company_id'] ?? 0 ); + $clean['default_project_id'] = absint( $input['default_project_id'] ?? 0 ); + + $clean['enable_cache'] = ! empty( $input['enable_cache'] ); + $clean['cache_floor'] = max( 30, min( DAY_IN_SECONDS, absint( $input['cache_floor'] ?? 300 ) ) ); + $clean['cache_multiplier'] = max( 10, min( 1000, absint( $input['cache_multiplier'] ?? 100 ) ) ); + $clean['per_page'] = max( 1, min( 2000, absint( $input['per_page'] ?? 100 ) ) ); + $clean['request_timeout'] = max( 5, min( 60, absint( $input['request_timeout'] ?? 15 ) ) ); + + $clean['suppress_emails'] = ! empty( $input['suppress_emails'] ); + $clean['date_format'] = sanitize_text_field( (string) ( $input['date_format'] ?? '' ) ); + $clean['currency_symbol'] = sanitize_text_field( (string) ( $input['currency_symbol'] ?? '$' ) ); + $clean['custom_css'] = wp_strip_all_tags( (string) ( $input['custom_css'] ?? '' ) ); + $clean['load_styles'] = ! empty( $input['load_styles'] ); + + $clean['enable_rest'] = ! empty( $input['enable_rest'] ); + $clean['rest_access'] = in_array( $input['rest_access'] ?? '', array( 'public', 'logged_in', 'editor' ), true ) + ? $input['rest_access'] + : 'logged_in'; + $clean['enable_blocks'] = ! empty( $input['enable_blocks'] ); + $clean['enable_logging'] = ! empty( $input['enable_logging'] ); + $clean['keep_data'] = ! empty( $input['keep_data'] ); + + self::flush(); + + /** + * Filters the sanitized settings before they are stored. + * + * @since 2.0.0 + * + * @param array $clean Sanitized settings. + * @param array $input Raw submitted values. + */ + return (array) apply_filters( 'procore_connect_sanitize_settings', $clean, $input ); + } + + /** + * Import settings saved by ProcoreWP 1.x. + * + * The 1.x option stored the secret in plain text and held a token obtained + * from the wrong host, so the secret is re-encrypted and the token dropped. + * + * @return bool True when a legacy option was migrated. + */ + public static function migrate_legacy(): bool { + $legacy = get_option( self::LEGACY_OPTION ); + + if ( ! is_array( $legacy ) || empty( $legacy ) ) { + return false; + } + + $current = self::all(); + + if ( ! empty( $legacy['client_id'] ) && '' === (string) $current['client_id'] ) { + $current['client_id'] = sanitize_text_field( (string) $legacy['client_id'] ); + } + + if ( ! empty( $legacy['client_secret'] ) && '' === (string) $current['client_secret'] ) { + $current['client_secret'] = Encryption::encrypt( (string) $legacy['client_secret'] ); + } + + if ( ! empty( $legacy['default_company_id'] ) && 0 === (int) $current['default_company_id'] ) { + $current['default_company_id'] = absint( $legacy['default_company_id'] ); + } + + update_option( self::OPTION, $current, false ); + update_option( 'procore_connect_migrated_from', '1.x', false ); + delete_option( self::LEGACY_OPTION ); + + self::flush(); + + return true; + } +} diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php new file mode 100644 index 0000000..2504915 --- /dev/null +++ b/src/Admin/SettingsPage.php @@ -0,0 +1,817 @@ + 'array', + 'sanitize_callback' => array( Settings::class, 'sanitize' ), + 'default' => Settings::defaults(), + 'show_in_rest' => false, + ) + ); + } + + /** + * Enqueue the admin script on this plugin's screens only. + * + * @param string $hook Current admin page hook suffix. + * @return void + */ + public function enqueue( string $hook ): void { + if ( 'toplevel_page_' . self::SLUG !== $hook ) { + return; + } + + wp_enqueue_script( + 'procore-connect-admin', + PROCORE_CONNECT_URL . 'assets/js/admin.js', + array( 'wp-i18n' ), + PROCORE_CONNECT_VERSION, + true + ); + + wp_localize_script( + 'procore-connect-admin', + 'procoreConnectAdmin', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( Ajax::NONCE ), + 'strings' => array( + 'testing' => __( 'Running checks…', 'procore-connect' ), + 'clearing' => __( 'Clearing…', 'procore-connect' ), + 'loading' => __( 'Loading…', 'procore-connect' ), + 'failed' => __( 'The request failed. Check your connection and try again.', 'procore-connect' ), + 'copied' => __( 'Copied', 'procore-connect' ), + 'confirmed' => __( 'Disconnect this site from Procore?', 'procore-connect' ), + 'skipped' => __( 'Skipped', 'procore-connect' ), + ), + ) + ); + + wp_enqueue_style( + 'procore-connect-admin', + PROCORE_CONNECT_URL . 'assets/css/admin.css', + array(), + PROCORE_CONNECT_VERSION + ); + } + + /** + * Render the current tab. + * + * @return void + */ + public function render(): void { + if ( ! current_user_can( self::CAPABILITY ) ) { + wp_die( esc_html__( 'You do not have permission to view this page.', 'procore-connect' ), '', array( 'response' => 403 ) ); + } + + $tabs = array( + 'connection' => __( 'Connection', 'procore-connect' ), + 'display' => __( 'Display', 'procore-connect' ), + 'cache' => __( 'Cache', 'procore-connect' ), + 'shortcodes' => __( 'Shortcodes', 'procore-connect' ), + 'status' => __( 'Status', 'procore-connect' ), + 'tools' => __( 'Tools', 'procore-connect' ), + ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab selector. + $active = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connection'; + $active = array_key_exists( $active, $tabs ) ? $active : 'connection'; + + echo '
'; + echo '

' . esc_html__( 'Procore Connect', 'procore-connect' ) . '

'; + + echo ''; + + switch ( $active ) { + case 'display': + $this->render_form( array( $this, 'fields_display' ) ); + break; + + case 'cache': + $this->render_form( array( $this, 'fields_cache' ) ); + break; + + case 'shortcodes': + $this->render_shortcodes(); + break; + + case 'status': + $this->render_status(); + break; + + case 'tools': + $this->render_form( array( $this, 'fields_tools' ) ); + break; + + default: + $this->render_connection(); + break; + }//end switch + + echo '
'; + } + + /** + * Wrap a set of fields in the options form. + * + * @param callable $fields Callback that prints the table rows. + * @return void + */ + private function render_form( callable $fields ): void { + echo '
'; + settings_fields( Settings::GROUP ); + echo ''; + call_user_func( $fields ); + echo ''; + submit_button(); + echo '
'; + } + + /** + * Render the Connection tab. + * + * @return void + */ + private function render_connection(): void { + $this->render_form( array( $this, 'fields_connection' ) ); + + $auth_mode = (string) Settings::get( 'auth_mode', 'client_credentials' ); + + if ( 'authorization_code' === $auth_mode ) { + echo '
'; + echo '

' . esc_html__( 'Authorize this site', 'procore-connect' ) . '

'; + echo '

' . esc_html__( 'Register this exact redirect URI in the Procore Developer Portal before connecting:', 'procore-connect' ) . '

'; + + printf( + '

%1$s

', + esc_attr( Environment::redirect_uri() ), + esc_html__( 'Copy', 'procore-connect' ) + ); + + echo '

'; + + if ( '' !== TokenStore::refresh_token() ) { + printf( + '', + esc_html__( 'Disconnect from Procore', 'procore-connect' ) + ); + } else { + printf( + '%s', + esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=procore_connect_oauth_connect' ), OAuthController::NONCE ) ), + esc_html__( 'Connect to Procore', 'procore-connect' ) + ); + } + + echo '

'; + }//end if + + echo '
'; + echo '

' . esc_html__( 'Verify the connection', 'procore-connect' ) . '

'; + echo '

' . esc_html__( 'Runs through authentication, company access and every endpoint the shortcodes use, so you can see exactly which Procore tool permissions your credentials have.', 'procore-connect' ) . '

'; + printf( + '

', + esc_html__( 'Test connection', 'procore-connect' ) + ); + echo '
'; + } + + /** + * Print the Connection tab fields. + * + * @return void + */ + private function fields_connection(): void { + $settings = Settings::all(); + $constants = Settings::credentials_are_constants(); + $has_secret = '' !== Settings::client_secret(); + + $this->row( + __( 'Authentication', 'procore-connect' ), + function () use ( $settings ) { + $modes = array( + 'client_credentials' => __( 'Service Account (Client Credentials) — recommended', 'procore-connect' ), + 'authorization_code' => __( 'User Account (Authorization Code)', 'procore-connect' ), + ); + + foreach ( $modes as $value => $label ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $value ), + checked( $settings['auth_mode'], $value, false ), + esc_html( $label ) + ); + } + + echo '

' . esc_html__( 'A service account keeps working when staff change and is installed by a Procore company administrator from the App Marketplace. A user connection inherits one person\'s permissions and breaks if their access is revoked.', 'procore-connect' ) . '

'; + } + ); + + $this->row( + __( 'Environment', 'procore-connect' ), + function () use ( $settings ) { + printf( ''; + echo '

' . esc_html__( 'Each environment needs its own credentials; tokens are not shared between them.', 'procore-connect' ) . '

'; + + printf( + '

', + esc_html__( 'Custom login host', 'procore-connect' ), + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['custom_login_url'] ) + ); + + printf( + '

', + esc_html__( 'Custom API host', 'procore-connect' ), + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['custom_api_url'] ) + ); + + echo '

' . esc_html__( 'Only used when the environment is set to Custom, for regional and federal zones.', 'procore-connect' ) . '

'; + } + ); + + if ( $constants ) { + $this->row( + __( 'Credentials', 'procore-connect' ), + function () { + echo '

' . esc_html__( 'Credentials are supplied by PROCORE_CONNECT_CLIENT_ID and PROCORE_CONNECT_CLIENT_SECRET in wp-config.php and are not stored in the database. Remove those constants to manage credentials here.', 'procore-connect' ) . '

'; + } + ); + } else { + $this->row( + __( 'Client ID', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['client_id'] ) + ); + } + ); + + $this->row( + __( 'Client Secret', 'procore-connect' ), + function () use ( $has_secret ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $has_secret ? Encryption::mask( Settings::client_secret() ) : __( 'Not set', 'procore-connect' ) ) + ); + + echo '

' . esc_html__( 'The stored secret is never shown. Leave this blank to keep the current value.', 'procore-connect' ) . '

'; + + if ( $has_secret ) { + printf( + '

', + esc_attr( Settings::OPTION ), + esc_html__( 'Delete the stored secret', 'procore-connect' ) + ); + } + + echo '

' . esc_html__( 'For the strongest protection, define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead of storing it here.', 'procore-connect' ) . '

'; + } + ); + }//end if + + $this->row( + __( 'Default company', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + absint( $settings['default_company_id'] ) + ); + + printf( + ' ', + esc_html__( 'Look up companies', 'procore-connect' ) + ); + + echo '
'; + echo '

' . esc_html__( 'Used whenever a shortcode omits company_id. Also visible in your Procore URL: app.procore.com/companies/XXXX/.', 'procore-connect' ) . '

'; + } + ); + + $this->row( + __( 'Default project', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + absint( $settings['default_project_id'] ) + ); + + echo '

' . esc_html__( 'Optional. Used whenever a project-scoped shortcode omits project_id.', 'procore-connect' ) . '

'; + } + ); + } + + /** + * Print the Display tab fields. + * + * @return void + */ + private function fields_display(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'Privacy', 'procore-connect' ), + 'suppress_emails', + (bool) $settings['suppress_emails'], + __( 'Never output email addresses on the front end', 'procore-connect' ), + __( 'Recommended. Procore project directories contain personal data; publishing them exposes staff and subcontractors to harvesting. When this is off, addresses are still obfuscated and each shortcode must additionally opt in with show_email="true".', 'procore-connect' ) + ); + + $this->text( + __( 'Date format', 'procore-connect' ), + 'date_format', + (string) $settings['date_format'], + __( 'Leave blank to use the site date format.', 'procore-connect' ), + (string) get_option( 'date_format', 'F j, Y' ) + ); + + $this->text( + __( 'Currency symbol', 'procore-connect' ), + 'currency_symbol', + (string) $settings['currency_symbol'], + __( 'Prefixed to monetary values such as change order totals.', 'procore-connect' ), + '$' + ); + + $this->checkbox( + __( 'Stylesheet', 'procore-connect' ), + 'load_styles', + (bool) $settings['load_styles'], + __( 'Load the Procore Connect stylesheet', 'procore-connect' ), + __( 'The stylesheet loads only on pages that actually contain Procore Connect output. Turn it off entirely if your theme styles the markup itself.', 'procore-connect' ) + ); + + $this->row( + __( 'Custom CSS', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_textarea( (string) $settings['custom_css'] ) + ); + + echo '

' . esc_html__( 'Added inline after the plugin stylesheet. This survives plugin updates — editing files inside the plugin directory does not.', 'procore-connect' ) . '

'; + } + ); + + $this->row( + __( 'Template overrides', 'procore-connect' ), + function () { + echo '

' . esc_html__( 'To change the markup, copy a file from the plugin\'s templates directory into your theme:', 'procore-connect' ) . '

'; + printf( + '

%s%s

', + esc_html( 'wp-content/plugins/procore-connect/templates/collection.php' ), + esc_html( 'wp-content/themes/' . get_stylesheet() . '/procore-connect/collection.php' ) + ); + echo '

' . esc_html__( 'Available templates: collection, record, field, image, map.', 'procore-connect' ) . '

'; + } + ); + } + + /** + * Print the Cache tab fields. + * + * @return void + */ + private function fields_cache(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'Response cache', 'procore-connect' ), + 'enable_cache', + (bool) $settings['enable_cache'], + __( 'Cache Procore responses', 'procore-connect' ), + __( 'Strongly recommended. Procore enforces both an hourly and a ten-second rate limit; an uncached page will exhaust the quota quickly. When caching is on, a Procore outage serves the last good response instead of an error.', 'procore-connect' ) + ); + + $this->number( + __( 'Minimum cache lifetime', 'procore-connect' ), + 'cache_floor', + (int) $settings['cache_floor'], + 30, + DAY_IN_SECONDS, + __( 'Seconds. Shortcodes may request a longer lifetime but never a shorter one, so a single page cannot be configured into a rate-limit breach.', 'procore-connect' ) + ); + + $this->number( + __( 'Records per request', 'procore-connect' ), + 'per_page', + (int) $settings['per_page'], + 1, + 2000, + __( 'Procore recommends keeping this at or below 2000.', 'procore-connect' ) + ); + + $this->number( + __( 'Request timeout', 'procore-connect' ), + 'request_timeout', + (int) $settings['request_timeout'], + 5, + 60, + __( 'Seconds to wait for Procore before giving up.', 'procore-connect' ) + ); + + $this->row( + __( 'Cached responses', 'procore-connect' ), + function () { + $stats = Cache::stats(); + + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of cached responses. */ + _n( '%d cached response.', '%d cached responses.', (int) $stats['total'], 'procore-connect' ), + (int) $stats['total'] + ) + ) + ); + + printf( + '

', + esc_html__( 'Clear cache now', 'procore-connect' ) + ); + } + ); + } + + /** + * Print the Tools tab fields. + * + * @return void + */ + private function fields_tools(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'REST proxy', 'procore-connect' ), + 'enable_rest', + (bool) $settings['enable_rest'], + __( 'Expose cached Procore data at /wp-json/procore-connect/v1/', 'procore-connect' ), + __( 'Lets themes and JavaScript read Procore data without ever seeing your credentials. Only endpoints published in the registry are reachable.', 'procore-connect' ) + ); + + $this->row( + __( 'REST access', 'procore-connect' ), + function () use ( $settings ) { + $levels = array( + 'public' => __( 'Anyone', 'procore-connect' ), + 'logged_in' => __( 'Logged-in users', 'procore-connect' ), + 'editor' => __( 'Users who can edit posts', 'procore-connect' ), + ); + + printf( ''; + } + ); + + $this->checkbox( + __( 'Block editor', 'procore-connect' ), + 'enable_blocks', + (bool) $settings['enable_blocks'], + __( 'Register Procore Connect blocks', 'procore-connect' ), + __( 'Adds a block for each shortcode with a live preview. Shortcodes keep working either way.', 'procore-connect' ) + ); + + $this->checkbox( + __( 'Diagnostics', 'procore-connect' ), + 'enable_logging', + (bool) $settings['enable_logging'], + __( 'Record API diagnostics', 'procore-connect' ), + __( 'Keeps the last 50 events for the Status tab. Credentials and tokens are redacted before anything is written.', 'procore-connect' ) + ); + + $this->checkbox( + __( 'Uninstall', 'procore-connect' ), + 'keep_data', + (bool) $settings['keep_data'], + __( 'Keep settings and cached data when the plugin is deleted', 'procore-connect' ), + __( 'Off by default, so deleting the plugin removes its credentials, tokens and cache.', 'procore-connect' ) + ); + } + + /** + * Render the Shortcodes reference tab. + * + * @return void + */ + private function render_shortcodes(): void { + echo '

' . esc_html__( 'Every shortcode below accepts the shared attributes company_id, project_id, limit, page, orderby, order, columns, template, class, title, cache and empty_text. The legacy id attribute is still accepted as an alias for project_id.', 'procore-connect' ) . '

'; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( Registrar::definitions() as $tag => $definition ) { + $endpoint = Endpoints::get( (string) $definition['endpoint'] ); + $permission = null !== $endpoint ? (string) $endpoint['permission'] : __( 'Depends on the endpoint attribute', 'procore-connect' ); + + echo ''; + printf( + '', + esc_html( $tag ) + ); + printf( '', esc_html( (string) $definition['description'] ) ); + printf( '', esc_html( $permission ) ); + echo ''; + } + + echo '
' . esc_html__( 'Shortcode', 'procore-connect' ) . '' . esc_html__( 'What it shows', 'procore-connect' ) . '' . esc_html__( 'Procore permission needed', 'procore-connect' ) . '
[%1$s]%s%s
'; + + echo '

' . esc_html__( 'Available endpoints for [procore_data]', 'procore-connect' ) . '

'; + echo '

' . esc_html( implode( ', ', array_keys( Endpoints::public_endpoints() ) ) ) . '

'; + } + + /** + * Render the Status tab. + * + * @return void + */ + private function render_status(): void { + $tokens = TokenStore::all(); + $limit = Client::rate_limit(); + $circuit = Client::circuit_state(); + + $rows = array( + __( 'Plugin version', 'procore-connect' ) => PROCORE_CONNECT_VERSION, + __( 'WordPress', 'procore-connect' ) => get_bloginfo( 'version' ), + __( 'PHP', 'procore-connect' ) => PHP_VERSION, + __( 'Environment', 'procore-connect' ) => Environment::choices()[ Environment::current() ], + __( 'API host', 'procore-connect' ) => Environment::api_host(), + __( 'Login host', 'procore-connect' ) => Environment::login_host(), + __( 'Redirect URI', 'procore-connect' ) => Environment::redirect_uri(), + __( 'Authentication', 'procore-connect' ) => Client::instance()->auth()->label(), + __( 'Credential storage', 'procore-connect' ) => Settings::credentials_are_constants() + ? __( 'wp-config.php constants', 'procore-connect' ) + : ( Encryption::is_strong() ? __( 'Encrypted (AES-256-GCM)', 'procore-connect' ) : __( 'Unencrypted — OpenSSL unavailable', 'procore-connect' ) ), + __( 'Token expires', 'procore-connect' ) => $tokens['expires_at'] > 0 + ? wp_date( 'Y-m-d H:i:s', $tokens['expires_at'] ) + : __( 'No token stored', 'procore-connect' ), + __( 'Rate limit', 'procore-connect' ) => $limit['limit'] > 0 + ? sprintf( '%d / %d', $limit['remaining'], $limit['limit'] ) + : __( 'Not reported yet', 'procore-connect' ), + __( 'Circuit breaker', 'procore-connect' ) => $circuit['open_until'] > time() + ? sprintf( + /* translators: %s: human readable time difference. */ + __( 'Open — resumes in %s', 'procore-connect' ), + human_time_diff( time(), $circuit['open_until'] ) + ) + : __( 'Closed', 'procore-connect' ), + __( 'Cached responses', 'procore-connect' ) => (string) Cache::stats()['total'], + __( 'Object cache', 'procore-connect' ) => wp_using_ext_object_cache() ? __( 'Persistent', 'procore-connect' ) : __( 'Database transients', 'procore-connect' ), + ); + + echo ''; + + foreach ( $rows as $label => $value ) { + printf( '', esc_html( (string) $label ), esc_html( (string) $value ) ); + } + + echo '
%1$s%2$s
'; + + $entries = Logger::entries(); + + echo '

' . esc_html__( 'Recent diagnostics', 'procore-connect' ) . '

'; + + if ( empty( $entries ) ) { + echo '

' . esc_html__( 'Nothing recorded. Turn on diagnostics under Tools to start collecting events.', 'procore-connect' ) . '

'; + + return; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $entries as $entry ) { + printf( + '', + esc_html( wp_date( 'Y-m-d H:i:s', (int) $entry['time'] ) ), + esc_html( (string) $entry['level'] ), + esc_html( (string) $entry['message'] ) + ); + } + + echo '
' . esc_html__( 'When', 'procore-connect' ) . '' . esc_html__( 'Level', 'procore-connect' ) . '' . esc_html__( 'Event', 'procore-connect' ) . '
%1$s%2$s%3$s
'; + } + + /** + * Print a settings table row. + * + * @param string $label Row label. + * @param callable $control Callback that prints the control markup. + * @return void + */ + private function row( string $label, callable $control ): void { + echo '' . esc_html( $label ) . ''; + call_user_func( $control ); + echo ''; + } + + /** + * Print a checkbox row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param bool $checked Current value. + * @param string $control_text Checkbox label. + * @param string $description Help text. + * @return void + */ + private function checkbox( string $label, string $key, bool $checked, string $control_text, string $description = '' ): void { + $this->row( + $label, + function () use ( $key, $checked, $control_text, $description ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + checked( $checked, true, false ), + esc_html( $control_text ) + ); + + if ( '' !== $description ) { + echo '

' . esc_html( $description ) . '

'; + } + } + ); + } + + /** + * Print a text input row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param string $value Current value. + * @param string $description Help text. + * @param string $placeholder Placeholder text. + * @return void + */ + private function text( string $label, string $key, string $value, string $description = '', string $placeholder = '' ): void { + $this->row( + $label, + function () use ( $key, $value, $description, $placeholder ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + esc_attr( $value ), + esc_attr( $placeholder ) + ); + + if ( '' !== $description ) { + echo '

' . esc_html( $description ) . '

'; + } + } + ); + } + + /** + * Print a number input row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param int $value Current value. + * @param int $min Minimum accepted value. + * @param int $max Maximum accepted value. + * @param string $description Help text. + * @return void + */ + private function number( string $label, string $key, int $value, int $min, int $max, string $description = '' ): void { + $this->row( + $label, + function () use ( $key, $value, $min, $max, $description ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + esc_attr( (string) $value ), + esc_attr( (string) $min ), + esc_attr( (string) $max ) + ); + + if ( '' !== $description ) { + echo '

' . esc_html( $description ) . '

'; + } + } + ); + } +} diff --git a/src/Api/Auth/AbstractAuth.php b/src/Api/Auth/AbstractAuth.php new file mode 100644 index 0000000..009b711 --- /dev/null +++ b/src/Api/Auth/AbstractAuth.php @@ -0,0 +1,130 @@ + $body Request parameters. + * @return array|\WP_Error Decoded token response, or an error. + */ + protected function request_token( array $body ) { + $client_id = Settings::client_id(); + $client_secret = Settings::client_secret(); + + if ( '' === $client_id || '' === $client_secret ) { + return new \WP_Error( + 'procore_connect_missing_credentials', + __( 'Enter your Procore Client ID and Client Secret before connecting.', 'procore-connect' ) + ); + } + + $body['client_id'] = $client_id; + $body['client_secret'] = $client_secret; + + $response = wp_remote_post( + Environment::token_url(), + array( + 'timeout' => 20, + 'sslverify' => true, + 'user-agent' => $this->user_agent(), + 'headers' => array( + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ), + 'body' => $body, + ) + ); + + if ( is_wp_error( $response ) ) { + Logger::error( 'Token request transport failure.', array( 'error' => $response->get_error_message() ) ); + + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $decoded ) ) { + Logger::error( 'Token endpoint returned an unreadable body.', array( 'status' => $code ) ); + + return new \WP_Error( + 'procore_connect_token_unreadable', + __( 'Procore returned an unreadable authentication response.', 'procore-connect' ) + ); + } + + if ( $code >= 400 || isset( $decoded['error'] ) ) { + $message = (string) ( $decoded['error_description'] ?? $decoded['error'] ?? '' ); + + if ( '' === $message ) { + /* translators: %d: HTTP status code. */ + $message = sprintf( __( 'Procore rejected the authentication request (HTTP %d).', 'procore-connect' ), $code ); + } + + Logger::error( + 'Token endpoint rejected the request.', + array( + 'status' => $code, + 'message' => $message, + ) + ); + + return new \WP_Error( 'procore_connect_token_rejected', $message, array( 'status' => $code ) ); + } + + if ( empty( $decoded['access_token'] ) ) { + return new \WP_Error( + 'procore_connect_token_missing', + __( 'Procore did not return an access token.', 'procore-connect' ) + ); + } + + return $decoded; + } + + /** + * User agent sent with authentication requests. + * + * @return string User agent string. + */ + protected function user_agent(): string { + return sprintf( + 'Procore Connect/%s; WordPress/%s; %s', + PROCORE_CONNECT_VERSION, + get_bloginfo( 'version' ), + home_url( '/' ) + ); + } + + /** + * Discard any stored token so the next request re-authenticates. + * + * @return void + */ + public function reset(): void { + TokenStore::clear(); + } +} diff --git a/src/Api/Auth/AuthInterface.php b/src/Api/Auth/AuthInterface.php new file mode 100644 index 0000000..5d31a44 --- /dev/null +++ b/src/Api/Auth/AuthInterface.php @@ -0,0 +1,46 @@ +request_token( + array( + 'grant_type' => 'refresh_token', + 'refresh_token' => $refresh_token, + 'redirect_uri' => Environment::redirect_uri(), + ) + ); + + if ( is_wp_error( $response ) ) { + // A rejected refresh token can never recover; force a reconnect. + if ( 'procore_connect_token_rejected' === $response->get_error_code() ) { + TokenStore::clear(); + Logger::error( 'Refresh token rejected by Procore; the connection has been reset.' ); + + return new \WP_Error( + 'procore_connect_reconnect_required', + __( 'The Procore connection has expired. Open Procore → Connection and reconnect.', 'procore-connect' ) + ); + } + + return $response; + } + + TokenStore::store( $response, $this->grant_type() ); + Logger::info( 'Refreshed the Procore user access token.' ); + + return (string) $response['access_token']; + } finally { + TokenStore::release_lock(); + }//end try + } + + /** + * Build the Procore authorization URL and register its CSRF state. + * + * @return string Authorization URL. + */ + public function authorization_url(): string { + $state = wp_generate_password( 32, false ); + + set_transient( self::STATE_PREFIX . $state, get_current_user_id(), self::STATE_TTL ); + + return add_query_arg( + array( + 'client_id' => rawurlencode( Settings::client_id() ), + 'response_type' => 'code', + 'redirect_uri' => rawurlencode( Environment::redirect_uri() ), + 'state' => rawurlencode( $state ), + ), + Environment::authorize_url() + ); + } + + /** + * Exchange an authorization code for a token pair. + * + * @param string $code Authorization code returned by Procore. + * @param string $state CSRF state value returned by Procore. + * @return true|\WP_Error True on success, or an error describing the failure. + */ + public function exchange_code( string $code, string $state ) { + if ( '' === $state || false === get_transient( self::STATE_PREFIX . $state ) ) { + return new \WP_Error( + 'procore_connect_bad_state', + __( 'The Procore authorization response could not be verified. Please start the connection again.', 'procore-connect' ) + ); + } + + delete_transient( self::STATE_PREFIX . $state ); + + if ( '' === $code ) { + return new \WP_Error( + 'procore_connect_missing_code', + __( 'Procore did not return an authorization code.', 'procore-connect' ) + ); + } + + $response = $this->request_token( + array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => Environment::redirect_uri(), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + TokenStore::store( $response, $this->grant_type() ); + Logger::info( 'Completed the Procore authorization code exchange.' ); + + return true; + } + + /** + * Whether the site currently holds a user connection. + * + * @return bool True when a refresh token is stored. + */ + public function is_connected(): bool { + return '' !== TokenStore::refresh_token(); + } + + /** + * Whether a client ID and secret are available. + * + * @return bool True when configured. + */ + public function is_configured(): bool { + return '' !== Settings::client_id() && '' !== Settings::client_secret(); + } + + /** + * Grant type identifier. + * + * @return string Grant type. + */ + public function grant_type(): string { + return 'authorization_code'; + } + + /** + * Human readable name. + * + * @return string Label. + */ + public function label(): string { + return __( 'User Account (Authorization Code)', 'procore-connect' ); + } +} diff --git a/src/Api/Auth/ClientCredentials.php b/src/Api/Auth/ClientCredentials.php new file mode 100644 index 0000000..2d932da --- /dev/null +++ b/src/Api/Auth/ClientCredentials.php @@ -0,0 +1,96 @@ +request_token( array( 'grant_type' => 'client_credentials' ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + TokenStore::store( $response, $this->grant_type() ); + Logger::info( 'Obtained a service account access token.' ); + + return (string) $response['access_token']; + } finally { + TokenStore::release_lock(); + } + } + + /** + * Whether a client ID and secret are available. + * + * @return bool True when configured. + */ + public function is_configured(): bool { + return '' !== Settings::client_id() && '' !== Settings::client_secret(); + } + + /** + * Grant type identifier. + * + * @return string Grant type. + */ + public function grant_type(): string { + return 'client_credentials'; + } + + /** + * Human readable name. + * + * @return string Label. + */ + public function label(): string { + return __( 'Service Account (Client Credentials)', 'procore-connect' ); + } +} diff --git a/src/Api/Cache.php b/src/Api/Cache.php new file mode 100644 index 0000000..84defbc --- /dev/null +++ b/src/Api/Cache.php @@ -0,0 +1,274 @@ + $args Request arguments. + * @param int $company Company identifier. + * @param int $project Project identifier. + * @return string Cache key. + */ + public static function key( string $slug, array $args, int $company, int $project ): string { + ksort( $args ); + + $signature = wp_json_encode( + array( + 'slug' => $slug, + 'args' => $args, + 'company' => $company, + 'project' => $project, + 'environment' => Environment::current(), + 'host' => Environment::api_host(), + ) + ); + + return md5( (string) $signature ); + } + + /** + * Read a fresh cache entry. + * + * @param string $key Cache key. + * @return mixed Cached value, or null on a miss. + */ + public static function get( string $key ) { + if ( ! self::enabled() ) { + return null; + } + + $value = get_transient( self::FRESH_PREFIX . $key ); + + return false === $value ? null : $value; + } + + /** + * Read the stale fallback copy of an entry. + * + * @param string $key Cache key. + * @return mixed Cached value, or null when no fallback exists. + */ + public static function get_stale( string $key ) { + $value = get_transient( self::STALE_PREFIX . $key ); + + return false === $value ? null : $value; + } + + /** + * Store a value in both the fresh and stale layers. + * + * @param string $key Cache key. + * @param mixed $value Value to store. + * @param int $ttl Fresh lifetime in seconds. + * @param string $group Cache group used for targeted purging. + * @return void + */ + public static function set( string $key, $value, int $ttl, string $group = 'default' ): void { + if ( ! self::enabled() ) { + return; + } + + $ttl = self::clamp_ttl( $ttl ); + + set_transient( self::FRESH_PREFIX . $key, $value, $ttl ); + set_transient( self::STALE_PREFIX . $key, $value, min( self::STALE_MAX_TTL, max( $ttl * 12, DAY_IN_SECONDS ) ) ); + + self::index( $group, $key ); + } + + /** + * Remove a single entry from both layers. + * + * @param string $key Cache key. + * @return void + */ + public static function delete( string $key ): void { + delete_transient( self::FRESH_PREFIX . $key ); + delete_transient( self::STALE_PREFIX . $key ); + } + + /** + * Purge cached responses. + * + * @param string $group Group to purge, or an empty string to purge everything. + * @return int Number of entries removed. + */ + public static function flush( string $group = '' ): int { + $index = self::read_index(); + $removed = 0; + + foreach ( $index as $indexed_group => $keys ) { + if ( '' !== $group && $indexed_group !== $group ) { + continue; + } + + foreach ( (array) $keys as $key ) { + self::delete( (string) $key ); + ++$removed; + } + + unset( $index[ $indexed_group ] ); + } + + if ( '' === $group ) { + delete_option( self::INDEX_OPTION ); + } else { + update_option( self::INDEX_OPTION, $index, false ); + } + + /** + * Fires after the Procore response cache has been purged. + * + * @since 2.0.0 + * + * @param string $group Purged group, or an empty string for a full purge. + * @param int $removed Number of entries removed. + */ + do_action( 'procore_connect_cache_flushed', $group, $removed ); + + return $removed; + } + + /** + * Summarise the current cache contents for the admin Status screen. + * + * @return array Entry count keyed by group, plus a `total` key. + */ + public static function stats(): array { + $index = self::read_index(); + $stats = array(); + $total = 0; + + foreach ( $index as $group => $keys ) { + $count = count( (array) $keys ); + $stats[ $group ] = $count; + $total += $count; + } + + $stats['total'] = $total; + + return $stats; + } + + /** + * Whether caching is currently active. + * + * @return bool True when enabled. + */ + public static function enabled(): bool { + /** + * Filters whether Procore responses are cached. + * + * @since 2.0.0 + * + * @param bool $enabled Whether caching is active. + */ + return (bool) apply_filters( 'procore_connect_cache_enabled', (bool) Settings::get( 'enable_cache', true ) ); + } + + /** + * Constrain a requested lifetime to the configured bounds. + * + * Shortcode authors may shorten a lifetime but never below the site-wide + * floor, so a single page cannot be configured into a rate-limit breach. + * + * @param int $ttl Requested lifetime in seconds. + * @return int Permitted lifetime in seconds. + */ + public static function clamp_ttl( int $ttl ): int { + $floor = (int) Settings::get( 'cache_floor', 60 ); + $floor = max( 30, $floor ); + + return max( $floor, min( $ttl, MONTH_IN_SECONDS ) ); + } + + /** + * Record a key against a group so it can be purged selectively. + * + * @param string $group Group name. + * @param string $key Cache key. + * @return void + */ + private static function index( string $group, string $key ): void { + $index = self::read_index(); + + if ( ! isset( $index[ $group ] ) || ! is_array( $index[ $group ] ) ) { + $index[ $group ] = array(); + } + + if ( in_array( $key, $index[ $group ], true ) ) { + return; + } + + $index[ $group ][] = $key; + + // Keep the index bounded; the oldest keys simply expire on their own. + if ( count( $index[ $group ] ) > 500 ) { + $index[ $group ] = array_slice( $index[ $group ], -500 ); + } + + update_option( self::INDEX_OPTION, $index, false ); + } + + /** + * Read the purge index. + * + * @return array> Index keyed by group. + */ + private static function read_index(): array { + $index = get_option( self::INDEX_OPTION, array() ); + + return is_array( $index ) ? $index : array(); + } +} diff --git a/src/Api/Client.php b/src/Api/Client.php new file mode 100644 index 0000000..b0e9660 --- /dev/null +++ b/src/Api/Client.php @@ -0,0 +1,727 @@ + + */ + private $meta = array(); + + /** + * Retrieve the shared client instance. + * + * @return self Client instance. + */ + public static function instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Replace the shared instance. Intended for tests. + * + * @param self|null $client Replacement client, or null to reset. + * @return void + */ + public static function set_instance( ?self $client ): void { + self::$instance = $client; + } + + /** + * Override the HTTP transport. Intended for tests. + * + * @param callable|null $transport Callable matching `wp_remote_request( $url, $args )`. + * @return void + */ + public function set_transport( ?callable $transport ): void { + $this->transport = $transport; + } + + /** + * Override the authentication strategy. + * + * @param AuthInterface|null $auth Strategy, or null to resolve from settings. + * @return void + */ + public function set_auth( ?AuthInterface $auth ): void { + $this->auth = $auth; + } + + /** + * The active authentication strategy. + * + * @return AuthInterface Strategy resolved from the saved settings. + */ + public function auth(): AuthInterface { + if ( null === $this->auth ) { + $this->auth = 'authorization_code' === Settings::get( 'auth_mode', 'client_credentials' ) + ? new AuthorizationCode() + : new ClientCredentials(); + } + + return $this->auth; + } + + /** + * Metadata describing the most recent fetch. + * + * @return array Metadata including `cached`, `stale` and `total`. + */ + public function meta(): array { + return $this->meta; + } + + /** + * Fetch a registered endpoint. + * + * @param string $slug Endpoint slug from the registry. + * @param array $params Additional query parameters. + * @param array $options Behaviour overrides: `company_id`, `project_id`, + * `ttl`, `all`, `per_page`, `max_pages`, `bypass_cache`. + * @return mixed|\WP_Error Decoded response body, or an error. + */ + public function fetch( string $slug, array $params = array(), array $options = array() ) { + $definition = Endpoints::get( $slug ); + + if ( null === $definition ) { + return new \WP_Error( + 'procore_connect_unknown_endpoint', + /* translators: %s: endpoint slug. */ + sprintf( __( 'Unknown Procore endpoint: %s', 'procore-connect' ), $slug ) + ); + } + + $company = isset( $options['company_id'] ) ? absint( $options['company_id'] ) : Settings::default_company_id(); + $project = isset( $options['project_id'] ) ? absint( $options['project_id'] ) : 0; + + if ( Endpoints::SCOPE_NONE !== $definition['scope'] && $company <= 0 ) { + return new \WP_Error( + 'procore_connect_missing_company', + __( 'No Procore company ID is available. Set a default company in Procore → Connection, or pass company_id.', 'procore-connect' ) + ); + } + + $path = Endpoints::path( $slug, $company, $project ); + + if ( is_wp_error( $path ) ) { + return $path; + } + + $query = array_merge( Endpoints::context_query( $slug, $company, $project ), $params ); + + if ( ! empty( $definition['paginated'] ) ) { + $query['per_page'] = min( 2000, max( 1, (int) ( $options['per_page'] ?? Settings::get( 'per_page', 100 ) ) ) ); + $query['page'] = max( 1, (int) ( $query['page'] ?? 1 ) ); + } + + $ttl = isset( $options['ttl'] ) ? (int) $options['ttl'] : (int) $definition['ttl']; + $collect = ! empty( $options['all'] ) && ! empty( $definition['paginated'] ); + $cache_key = Cache::key( $slug . ( $collect ? ':all' : '' ), $query, $company, $project ); + $this->meta = array( + 'endpoint' => $slug, + 'cached' => false, + 'stale' => false, + 'total' => null, + ); + + if ( empty( $options['bypass_cache'] ) ) { + $cached = Cache::get( $cache_key ); + + if ( null !== $cached ) { + $this->meta['cached'] = true; + $this->meta['total'] = is_array( $cached ) ? count( $cached ) : null; + + return $cached; + } + } + + if ( $this->circuit_is_open() ) { + return $this->stale_or_error( + $cache_key, + new \WP_Error( + 'procore_connect_circuit_open', + __( 'Procore requests are paused after repeated failures. They will resume automatically.', 'procore-connect' ) + ) + ); + } + + $result = $collect + ? $this->collect_pages( $path, $query, $company, (int) ( $options['max_pages'] ?? self::MAX_PAGES ) ) + : $this->request( $path, $query, $company ); + + if ( is_wp_error( $result ) ) { + $this->record_failure(); + + return $this->stale_or_error( $cache_key, $result ); + } + + $this->record_success(); + + Cache::set( $cache_key, $result['body'], $ttl, $slug ); + + $this->meta['total'] = $result['total']; + + return $result['body']; + } + + /** + * Issue a single API request with retries. + * + * @param string $path API path beginning with a slash. + * @param array $query Query parameters. + * @param int $company Company identifier for the scope header. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Response, or an error. + */ + private function request( string $path, array $query, int $company ) { + $url = Environment::api_host() . $path; + + if ( ! empty( $query ) ) { + $url = add_query_arg( array_map( 'rawurlencode', array_map( 'strval', $query ) ), $url ); + } + + return $this->send( $url, $company ); + } + + /** + * Walk `Link: rel="next"` headers to assemble a complete result set. + * + * @param string $path API path beginning with a slash. + * @param array $query Query parameters. + * @param int $company Company identifier for the scope header. + * @param int $max_pages Hard page ceiling. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Combined response, or an error. + */ + private function collect_pages( string $path, array $query, int $company, int $max_pages ) { + $max_pages = max( 1, min( self::MAX_PAGES, $max_pages ) ); + $response = $this->request( $path, $query, $company ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $rows = is_array( $response['body'] ) ? $response['body'] : array(); + $next = $response['next']; + $pages = 1; + + while ( null !== $next && $pages < $max_pages ) { + $page = $this->send( $next, $company ); + + if ( is_wp_error( $page ) ) { + // Return what was gathered rather than discarding a partial set. + Logger::warning( + 'Pagination stopped early.', + array( + 'error' => $page->get_error_message(), + 'pages' => $pages, + ) + ); + break; + } + + if ( is_array( $page['body'] ) ) { + $rows = array_merge( $rows, $page['body'] ); + } + + $next = $page['next']; + ++$pages; + }//end while + + if ( null !== $next && $pages >= $max_pages ) { + Logger::warning( + 'Pagination hit the page ceiling; the result set is truncated.', + array( + 'pages' => $pages, + 'path' => $path, + ) + ); + } + + return array( + 'body' => $rows, + 'total' => count( $rows ), + 'next' => null, + ); + } + + /** + * Perform an HTTP GET with retry and backoff. + * + * @param string $url Absolute request URL. + * @param int $company Company identifier for the scope header. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Response, or an error. + */ + private function send( string $url, int $company ) { + $last_error = null; + + for ( $attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++ ) { + $token = $this->auth()->access_token(); + + if ( is_wp_error( $token ) ) { + return $token; + } + + $headers = array( + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ); + + // Mandatory for /me, /companies and every multi-region request. + if ( $company > 0 ) { + $headers['Procore-Company-Id'] = (string) $company; + } + + $args = array( + 'method' => 'GET', + 'timeout' => (int) Settings::get( 'request_timeout', 15 ), + 'sslverify' => true, + 'redirection' => 3, + 'user-agent' => sprintf( 'Procore Connect/%s; WordPress/%s; %s', PROCORE_CONNECT_VERSION, get_bloginfo( 'version' ), home_url( '/' ) ), + 'headers' => $headers, + ); + + /** + * Filters the HTTP arguments for a Procore API request. + * + * @since 2.0.0 + * + * @param array $args Request arguments. + * @param string $url Request URL. + */ + $args = (array) apply_filters( 'procore_connect_request_args', $args, $url ); + + $response = $this->dispatch( $url, $args ); + + if ( is_wp_error( $response ) ) { + $last_error = $response; + + if ( $attempt < self::MAX_ATTEMPTS ) { + $this->sleep( $this->backoff( $attempt ) ); + continue; + } + + return $response; + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + + $this->record_rate_limit( $response ); + + if ( 401 === $status && $attempt < self::MAX_ATTEMPTS ) { + // The token was revoked or rotated server-side; drop it and retry once. + TokenStore::clear(); + $last_error = new \WP_Error( 'procore_connect_unauthorized', __( 'Procore rejected the access token.', 'procore-connect' ) ); + continue; + } + + if ( 429 === $status || 503 === $status ) { + $wait = $this->retry_delay( $response, $attempt ); + + if ( $attempt < self::MAX_ATTEMPTS ) { + Logger::warning( + 'Procore throttled the request; backing off.', + array( + 'status' => $status, + 'wait' => $wait, + ) + ); + $this->sleep( $wait ); + continue; + } + + return new \WP_Error( + 'procore_connect_rate_limited', + __( 'Procore is rate limiting this site. Cached data will be shown until the limit resets.', 'procore-connect' ), + array( 'status' => $status ) + ); + }//end if + + return $this->parse( $response, $status ); + }//end for + + return $last_error instanceof \WP_Error + ? $last_error + : new \WP_Error( 'procore_connect_request_failed', __( 'The Procore request could not be completed.', 'procore-connect' ) ); + } + + /** + * Send the request through the configured transport. + * + * @param string $url Request URL. + * @param array $args Request arguments. + * @return array|\WP_Error Raw WordPress HTTP response. + */ + private function dispatch( string $url, array $args ) { + if ( is_callable( $this->transport ) ) { + return call_user_func( $this->transport, $url, $args ); + } + + return wp_remote_request( $url, $args ); + } + + /** + * Decode a successful response and extract pagination metadata. + * + * @param array $response Raw WordPress HTTP response. + * @param int $status HTTP status code. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Parsed response, or an error. + */ + private function parse( $response, int $status ) { + $raw = (string) wp_remote_retrieve_body( $response ); + $decoded = '' === $raw ? array() : json_decode( $raw, true ); + + if ( $status >= 400 ) { + $message = ''; + + if ( is_array( $decoded ) ) { + $message = (string) ( $decoded['message'] ?? $decoded['error'] ?? '' ); + + if ( '' === $message && isset( $decoded['errors'] ) ) { + $message = is_array( $decoded['errors'] ) ? implode( '; ', array_map( 'strval', $decoded['errors'] ) ) : (string) $decoded['errors']; + } + } + + if ( '' === $message ) { + /* translators: %d: HTTP status code. */ + $message = sprintf( __( 'Procore returned HTTP %d.', 'procore-connect' ), $status ); + } + + Logger::error( + 'Procore API error.', + array( + 'status' => $status, + 'message' => $message, + ) + ); + + return new \WP_Error( 'procore_connect_api_error', $message, array( 'status' => $status ) ); + }//end if + + if ( null === $decoded && '' !== $raw ) { + return new \WP_Error( 'procore_connect_bad_json', __( 'Procore returned a response that could not be decoded.', 'procore-connect' ) ); + } + + $total = wp_remote_retrieve_header( $response, 'total' ); + + return array( + 'body' => $decoded, + 'total' => ( '' === $total || null === $total ) ? null : (int) $total, + 'next' => $this->next_link( $response ), + ); + } + + /** + * Extract the `rel="next"` URL from a Link header. + * + * @param array $response Raw WordPress HTTP response. + * @return string|null Next page URL, or null when this is the final page. + */ + private function next_link( $response ): ?string { + $link = wp_remote_retrieve_header( $response, 'link' ); + + if ( is_array( $link ) ) { + $link = implode( ', ', $link ); + } + + if ( ! is_string( $link ) || '' === $link ) { + return null; + } + + if ( ! preg_match( '/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i', $link, $matches ) ) { + return null; + } + + $url = esc_url_raw( trim( $matches[1] ), array( 'https' ) ); + + if ( '' === $url ) { + return null; + } + + // Never follow a Link header off the configured API host. + $host = wp_parse_url( $url, PHP_URL_HOST ); + $expected = wp_parse_url( Environment::api_host(), PHP_URL_HOST ); + + return ( $host && $host === $expected ) ? $url : null; + } + + /** + * Persist the rate-limit headers for the admin Status screen. + * + * @param array $response Raw WordPress HTTP response. + * @return void + */ + private function record_rate_limit( $response ): void { + $limit = wp_remote_retrieve_header( $response, 'x-rate-limit-limit' ); + $remaining = wp_remote_retrieve_header( $response, 'x-rate-limit-remaining' ); + $reset = wp_remote_retrieve_header( $response, 'x-rate-limit-reset' ); + + if ( '' === $limit && '' === $remaining ) { + return; + } + + update_option( + self::RATE_LIMIT_OPTION, + array( + 'limit' => (int) $limit, + 'remaining' => (int) $remaining, + 'reset' => (int) $reset, + 'updated' => time(), + ), + false + ); + } + + /** + * The most recent rate-limit snapshot. + * + * @return array Snapshot with `limit`, `remaining`, `reset` and `updated`. + */ + public static function rate_limit(): array { + $stored = get_option( self::RATE_LIMIT_OPTION, array() ); + + return wp_parse_args( + is_array( $stored ) ? $stored : array(), + array( + 'limit' => 0, + 'remaining' => 0, + 'reset' => 0, + 'updated' => 0, + ) + ); + } + + /** + * Determine how long to wait before retrying a throttled request. + * + * @param array $response Raw WordPress HTTP response. + * @param int $attempt Attempt number, starting at 1. + * @return int Seconds to wait. + */ + private function retry_delay( $response, int $attempt ): int { + $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' ); + + if ( $retry_after > 0 ) { + return min( 10, $retry_after ); + } + + $reset = (int) wp_remote_retrieve_header( $response, 'x-rate-limit-reset' ); + + if ( $reset > time() ) { + return min( 10, $reset - time() ); + } + + return $this->backoff( $attempt ); + } + + /** + * Exponential backoff with jitter. + * + * @param int $attempt Attempt number, starting at 1. + * @return int Seconds to wait. + */ + private function backoff( int $attempt ): int { + return min( 8, (int) pow( 2, $attempt ) ) + wp_rand( 0, 1 ); + } + + /** + * Pause execution between retries. + * + * @param int $seconds Seconds to sleep. + * @return void + */ + private function sleep( int $seconds ): void { + if ( $seconds <= 0 ) { + return; + } + + /** + * Filters the retry pause. Return zero to disable sleeping, as tests do. + * + * @since 2.0.0 + * + * @param int $seconds Seconds to sleep. + */ + $seconds = (int) apply_filters( 'procore_connect_retry_sleep', $seconds ); + + if ( $seconds > 0 ) { + sleep( min( 10, $seconds ) ); + } + } + + /** + * Serve the stale cached copy when one exists, otherwise return the error. + * + * @param string $cache_key Cache key. + * @param \WP_Error $error Error to return when no fallback exists. + * @return mixed|\WP_Error Stale payload, or the supplied error. + */ + private function stale_or_error( string $cache_key, \WP_Error $error ) { + $stale = Cache::get_stale( $cache_key ); + + if ( null === $stale ) { + return $error; + } + + $this->meta['cached'] = true; + $this->meta['stale'] = true; + + Logger::warning( + 'Serving a stale cached response after an API failure.', + array( 'error' => $error->get_error_message() ) + ); + + return $stale; + } + + /** + * Whether the circuit breaker is currently open. + * + * @return bool True when requests are paused. + */ + public function circuit_is_open(): bool { + $state = self::circuit_state(); + + return $state['open_until'] > time(); + } + + /** + * Current circuit breaker state. + * + * @return array State with `failures` and `open_until`. + */ + public static function circuit_state(): array { + $stored = get_option( self::CIRCUIT_OPTION, array() ); + + return wp_parse_args( + is_array( $stored ) ? $stored : array(), + array( + 'failures' => 0, + 'open_until' => 0, + ) + ); + } + + /** + * Reset the circuit breaker. + * + * @return void + */ + public static function reset_circuit(): void { + delete_option( self::CIRCUIT_OPTION ); + } + + /** + * Count a failed request towards the circuit breaker threshold. + * + * @return void + */ + private function record_failure(): void { + $state = self::circuit_state(); + ++$state['failures']; + + if ( $state['failures'] >= self::CIRCUIT_THRESHOLD ) { + $state['open_until'] = time() + self::CIRCUIT_COOLDOWN; + $state['failures'] = 0; + + Logger::error( 'Procore circuit breaker opened after repeated failures.' ); + } + + update_option( self::CIRCUIT_OPTION, $state, false ); + } + + /** + * Clear the failure counter after a successful request. + * + * @return void + */ + private function record_success(): void { + $state = self::circuit_state(); + + if ( 0 === $state['failures'] && 0 === $state['open_until'] ) { + return; + } + + self::reset_circuit(); + } +} diff --git a/src/Api/Endpoints.php b/src/Api/Endpoints.php new file mode 100644 index 0000000..523fbfb --- /dev/null +++ b/src/Api/Endpoints.php @@ -0,0 +1,414 @@ +>|null + */ + private static $registry = null; + + /** + * Retrieve the full endpoint registry. + * + * @return array> Registry keyed by endpoint slug. + */ + public static function all(): array { + if ( null !== self::$registry ) { + return self::$registry; + } + + $registry = array( + + /* Identity and company scope. */ + + 'me' => array( + 'label' => __( 'Authenticated account', 'procore-connect' ), + 'path' => '/rest/v1.0/me', + 'scope' => self::SCOPE_NONE, + 'paginated' => false, + 'ttl' => 300, + 'permission' => __( 'None (identity endpoint)', 'procore-connect' ), + 'fields' => array( 'id', 'login', 'name' ), + 'public' => false, + ), + 'companies' => array( + 'label' => __( 'Companies', 'procore-connect' ), + 'path' => '/rest/v1.0/companies', + 'scope' => self::SCOPE_NONE, + 'paginated' => true, + 'ttl' => 3600, + 'permission' => __( 'Company Directory: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'is_active' ), + 'public' => false, + ), + 'offices' => array( + 'label' => __( 'Company offices', 'procore-connect' ), + 'path' => '/rest/v1.0/companies/{company_id}/offices', + 'scope' => self::SCOPE_COMPANY, + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Company Admin: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'address', 'city', 'state_code', 'zip', 'phone' ), + 'public' => true, + ), + 'company_vendors' => array( + 'label' => __( 'Company directory (vendors)', 'procore-connect' ), + 'path' => '/rest/v1.0/vendors', + 'scope' => self::SCOPE_COMPANY, + 'query' => array( 'company_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Company Directory: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'city', 'state_code', 'business_phone', 'website' ), + 'public' => true, + ), + + /* Projects. */ + + 'projects' => array( + 'label' => __( 'Projects', 'procore-connect' ), + 'path' => '/rest/v1.1/projects', + 'scope' => self::SCOPE_COMPANY, + 'query' => array( 'company_id' ), + 'paginated' => true, + 'ttl' => 900, + 'permission' => __( 'Company Admin / Project Directory: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'project_number', 'city', 'state_code', 'active', 'stage' ), + 'public' => true, + ), + 'project' => array( + 'label' => __( 'Project detail', 'procore-connect' ), + 'path' => '/rest/v1.0/projects/{project_id}', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => false, + 'ttl' => 900, + 'permission' => __( 'Project Admin: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'address', 'city', 'state_code', 'zip', 'start_date', 'completion_date', 'active' ), + 'public' => true, + ), + 'project_users' => array( + 'label' => __( 'Project team', 'procore-connect' ), + 'path' => '/rest/v1.0/projects/{project_id}/users', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Project Directory: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'job_title', 'vendor', 'email_address' ), + 'public' => true, + ), + 'project_vendors' => array( + 'label' => __( 'Project vendors', 'procore-connect' ), + 'path' => '/rest/v1.0/projects/{project_id}/vendors', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Project Directory: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'city', 'state_code', 'business_phone' ), + 'public' => true, + ), + + /* Documents. */ + + 'drawing_areas' => array( + 'label' => __( 'Drawing areas', 'procore-connect' ), + 'path' => '/rest/v1.0/drawing_areas', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Drawings: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'description' ), + 'public' => true, + ), + 'drawing_revisions' => array( + 'label' => __( 'Drawing revisions', 'procore-connect' ), + 'path' => '/rest/v1.0/drawing_revisions', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Drawings: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'number', 'title', 'revision_number', 'received_date' ), + 'public' => true, + ), + 'specification_sections' => array( + 'label' => __( 'Specification sections', 'procore-connect' ), + 'path' => '/rest/v1.0/specification_sections', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Specifications: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'number', 'description', 'revision' ), + 'public' => true, + ), + + /* Project management tools. */ + + 'rfis' => array( + 'label' => __( 'RFIs', 'procore-connect' ), + 'path' => '/rest/v1.0/projects/{project_id}/rfis', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'RFIs: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'number', 'subject', 'status', 'due_date' ), + 'public' => true, + ), + 'submittals' => array( + 'label' => __( 'Submittals', 'procore-connect' ), + 'path' => '/rest/v1.0/projects/{project_id}/submittals', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'Submittals: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'number', 'title', 'status', 'due_date' ), + 'public' => true, + ), + 'punch_items' => array( + 'label' => __( 'Punch list', 'procore-connect' ), + 'path' => '/rest/v1.0/punch_items', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'Punch List: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'position', 'name', 'status', 'due_date' ), + 'public' => true, + ), + 'observations' => array( + 'label' => __( 'Observations', 'procore-connect' ), + 'path' => '/rest/v1.0/observations/items', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'Observations: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'number', 'name', 'status', 'due_date' ), + 'public' => true, + ), + 'daily_logs' => array( + 'label' => __( 'Daily construction report logs', 'procore-connect' ), + 'path' => '/rest/v1.0/daily_construction_report_logs', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Daily Log: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'date', 'comments', 'status' ), + 'public' => true, + ), + 'change_orders' => array( + 'label' => __( 'Change order packages', 'procore-connect' ), + 'path' => '/rest/v1.0/change_order_packages', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Change Orders: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'number', 'title', 'status', 'grand_total' ), + 'public' => true, + ), + 'milestones' => array( + 'label' => __( 'Schedule tasks', 'procore-connect' ), + 'path' => '/rest/v1.0/projects/{project_id}/schedule/tasks', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Schedule: Read Only', 'procore-connect' ), + 'fields' => array( 'id', 'name', 'start_date', 'finish_date', 'percent_complete' ), + 'public' => true, + ), + ); + + /** + * Filters the registry of callable Procore endpoints. + * + * Use this to correct a path after a Procore resource version bump, or + * to register an additional endpoint for `[procore_data]`. + * + * @since 2.0.0 + * + * @param array> $registry Endpoint definitions keyed by slug. + */ + $registry = (array) apply_filters( 'procore_connect_endpoints', $registry ); + + self::$registry = array_map( array( self::class, 'normalise' ), $registry ); + + return self::$registry; + } + + /** + * Retrieve a single endpoint definition. + * + * @param string $slug Endpoint slug. + * @return array|null Definition, or null when not registered. + */ + public static function get( string $slug ): ?array { + $all = self::all(); + + return $all[ $slug ] ?? null; + } + + /** + * Whether an endpoint slug is registered. + * + * @param string $slug Endpoint slug. + * @return bool True when registered. + */ + public static function exists( string $slug ): bool { + return null !== self::get( $slug ); + } + + /** + * Endpoints that may be reached by `[procore_data]` and the REST proxy. + * + * @return array> Public endpoint definitions. + */ + public static function public_endpoints(): array { + return array_filter( + self::all(), + static function ( array $definition ): bool { + return ! empty( $definition['public'] ); + } + ); + } + + /** + * Build the request path for an endpoint, substituting context identifiers. + * + * @param string $slug Endpoint slug. + * @param int $company Company identifier. + * @param int $project Project identifier. + * @return string|\WP_Error Resolved path, or an error when context is missing. + */ + public static function path( string $slug, int $company = 0, int $project = 0 ) { + $definition = self::get( $slug ); + + if ( null === $definition ) { + return new \WP_Error( + 'procore_connect_unknown_endpoint', + /* translators: %s: endpoint slug. */ + sprintf( __( 'Unknown Procore endpoint: %s', 'procore-connect' ), $slug ) + ); + } + + $path = (string) $definition['path']; + + if ( false !== strpos( $path, '{company_id}' ) ) { + if ( $company <= 0 ) { + return new \WP_Error( 'procore_connect_missing_company', __( 'A Procore company ID is required for this request.', 'procore-connect' ) ); + } + + $path = str_replace( '{company_id}', (string) $company, $path ); + } + + if ( false !== strpos( $path, '{project_id}' ) ) { + if ( $project <= 0 ) { + return new \WP_Error( 'procore_connect_missing_project', __( 'A Procore project ID is required for this request.', 'procore-connect' ) ); + } + + $path = str_replace( '{project_id}', (string) $project, $path ); + } + + if ( self::SCOPE_PROJECT === $definition['scope'] && $project <= 0 ) { + return new \WP_Error( 'procore_connect_missing_project', __( 'A Procore project ID is required for this request.', 'procore-connect' ) ); + } + + return $path; + } + + /** + * Query arguments an endpoint requires in addition to caller-supplied ones. + * + * @param string $slug Endpoint slug. + * @param int $company Company identifier. + * @param int $project Project identifier. + * @return array Query arguments. + */ + public static function context_query( string $slug, int $company = 0, int $project = 0 ): array { + $definition = self::get( $slug ); + + if ( null === $definition ) { + return array(); + } + + $query = array(); + + foreach ( (array) $definition['query'] as $key ) { + if ( 'company_id' === $key && $company > 0 ) { + $query['company_id'] = $company; + } + + if ( 'project_id' === $key && $project > 0 ) { + $query['project_id'] = $project; + } + } + + return $query; + } + + /** + * Apply defaults to a registry entry. + * + * @param array $definition Raw definition. + * @return array Definition with defaults applied. + */ + private static function normalise( array $definition ): array { + return wp_parse_args( + $definition, + array( + 'label' => '', + 'path' => '', + 'scope' => self::SCOPE_NONE, + 'query' => array(), + 'paginated' => false, + 'ttl' => 900, + 'permission' => '', + 'fields' => array(), + 'public' => false, + ) + ); + } +} diff --git a/src/Api/Environment.php b/src/Api/Environment.php new file mode 100644 index 0000000..780941c --- /dev/null +++ b/src/Api/Environment.php @@ -0,0 +1,188 @@ +> + */ + private const ENVIRONMENTS = array( + 'production' => array( + 'login' => 'https://login.procore.com', + 'api' => 'https://api.procore.com', + ), + 'sandbox' => array( + 'login' => 'https://login-sandbox.procore.com', + 'api' => 'https://sandbox.procore.com', + ), + 'sandbox_monthly' => array( + 'login' => 'https://login-sandbox-monthly.procore.com', + 'api' => 'https://api-monthly.procore.com', + ), + ); + + /** + * Environment identifiers and their translated labels. + * + * @return array Map of value to label. + */ + public static function choices(): array { + return array( + 'production' => __( 'Production', 'procore-connect' ), + 'sandbox' => __( 'Developer Sandbox', 'procore-connect' ), + 'sandbox_monthly' => __( 'Monthly Sandbox', 'procore-connect' ), + 'custom' => __( 'Custom (regional or federal zone)', 'procore-connect' ), + ); + } + + /** + * The currently configured environment identifier. + * + * @return string Environment key. + */ + public static function current(): string { + $value = (string) Settings::get( 'environment', 'production' ); + + return array_key_exists( $value, self::choices() ) ? $value : 'production'; + } + + /** + * Base URL of the OAuth login host, without a trailing slash. + * + * @return string Login host URL. + */ + public static function login_host(): string { + $environment = self::current(); + + if ( 'custom' === $environment ) { + return self::normalise( (string) Settings::get( 'custom_login_url', '' ), self::ENVIRONMENTS['production']['login'] ); + } + + /** + * Filters the Procore OAuth login host. + * + * @since 2.0.0 + * + * @param string $host Login host URL without a trailing slash. + * @param string $environment Active environment identifier. + */ + return (string) apply_filters( 'procore_connect_login_host', self::ENVIRONMENTS[ $environment ]['login'], $environment ); + } + + /** + * Base URL of the REST API host, without a trailing slash. + * + * @return string API host URL. + */ + public static function api_host(): string { + $environment = self::current(); + + if ( 'custom' === $environment ) { + return self::normalise( (string) Settings::get( 'custom_api_url', '' ), self::ENVIRONMENTS['production']['api'] ); + } + + /** + * Filters the Procore REST API host. + * + * @since 2.0.0 + * + * @param string $host API host URL without a trailing slash. + * @param string $environment Active environment identifier. + */ + return (string) apply_filters( 'procore_connect_api_host', self::ENVIRONMENTS[ $environment ]['api'], $environment ); + } + + /** + * Full URL of the OAuth token endpoint. + * + * @return string Token endpoint URL. + */ + public static function token_url(): string { + return self::login_host() . '/oauth/token'; + } + + /** + * Full URL of the OAuth authorization endpoint. + * + * @return string Authorize endpoint URL. + */ + public static function authorize_url(): string { + return self::login_host() . '/oauth/authorize'; + } + + /** + * The redirect URI this site presents during the Authorization Code flow. + * + * This exact value must be registered in the Procore Developer Portal. + * + * @return string Redirect URI. + */ + public static function redirect_uri(): string { + /** + * Filters the OAuth redirect URI registered with Procore. + * + * @since 2.0.0 + * + * @param string $uri Redirect URI. + */ + return (string) apply_filters( + 'procore_connect_redirect_uri', + admin_url( 'admin-post.php?action=procore_connect_oauth_callback' ) + ); + } + + /** + * Whether the active environment points at production Procore data. + * + * @return bool True for production. + */ + public static function is_production(): bool { + return 'production' === self::current(); + } + + /** + * Validate and normalise a user-supplied host URL. + * + * @param string $url Candidate URL. + * @param string $fallback Value used when the candidate is unusable. + * @return string Normalised URL without a trailing slash. + */ + private static function normalise( string $url, string $fallback ): string { + $url = trim( $url ); + + if ( '' === $url ) { + return $fallback; + } + + $url = esc_url_raw( $url, array( 'https' ) ); + $parts = wp_parse_url( $url ); + + if ( empty( $parts['host'] ) || empty( $parts['scheme'] ) || 'https' !== $parts['scheme'] ) { + return $fallback; + } + + return 'https://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); + } +} diff --git a/src/Api/TokenStore.php b/src/Api/TokenStore.php new file mode 100644 index 0000000..1f4e137 --- /dev/null +++ b/src/Api/TokenStore.php @@ -0,0 +1,212 @@ + Token payload with decrypted values. + */ + public static function all(): array { + $stored = get_option( self::OPTION, array() ); + + if ( ! is_array( $stored ) ) { + $stored = array(); + } + + $payload = wp_parse_args( + $stored, + array( + 'access_token' => '', + 'refresh_token' => '', + 'expires_at' => 0, + 'obtained_at' => 0, + 'grant_type' => '', + 'environment' => '', + ) + ); + + $payload['access_token'] = Encryption::decrypt( (string) $payload['access_token'] ); + $payload['refresh_token'] = Encryption::decrypt( (string) $payload['refresh_token'] ); + $payload['expires_at'] = (int) $payload['expires_at']; + $payload['obtained_at'] = (int) $payload['obtained_at']; + + return $payload; + } + + /** + * Persist a token response from Procore. + * + * @param array $response Decoded `/oauth/token` response body. + * @param string $grant_type Grant type that produced the token. + * @return void + */ + public static function store( array $response, string $grant_type ): void { + $expires_in = isset( $response['expires_in'] ) ? (int) $response['expires_in'] : 5400; + $expires_in = max( 60, $expires_in ); + + $payload = array( + 'access_token' => Encryption::encrypt( (string) ( $response['access_token'] ?? '' ) ), + 'refresh_token' => Encryption::encrypt( (string) ( $response['refresh_token'] ?? '' ) ), + 'expires_at' => time() + $expires_in - self::EXPIRY_MARGIN, + 'obtained_at' => time(), + 'grant_type' => $grant_type, + 'environment' => Environment::current(), + ); + + update_option( self::OPTION, $payload, false ); + + /** + * Fires after a Procore access token has been stored. + * + * @since 2.0.0 + * + * @param string $grant_type Grant type that produced the token. + * @param int $expires_at Unix timestamp at which the token should be considered expired. + */ + do_action( 'procore_connect_token_stored', $grant_type, (int) $payload['expires_at'] ); + } + + /** + * Discard all stored tokens. + * + * @return void + */ + public static function clear(): void { + delete_option( self::OPTION ); + self::release_lock(); + } + + /** + * Whether a usable, unexpired access token is stored for this environment. + * + * @return bool True when the stored token can be used as-is. + */ + public static function has_valid_token(): bool { + $payload = self::all(); + + if ( '' === $payload['access_token'] ) { + return false; + } + + // Tokens are not portable between production and sandbox. + if ( '' !== $payload['environment'] && Environment::current() !== $payload['environment'] ) { + return false; + } + + return $payload['expires_at'] > time(); + } + + /** + * The stored access token, without validity checks. + * + * @return string Access token, or an empty string when none is stored. + */ + public static function access_token(): string { + return (string) self::all()['access_token']; + } + + /** + * The stored refresh token. + * + * @return string Refresh token, or an empty string when none is stored. + */ + public static function refresh_token(): string { + return (string) self::all()['refresh_token']; + } + + /** + * Attempt to acquire the refresh lock. + * + * @return bool True when this process owns the lock. + */ + public static function acquire_lock(): bool { + // wp_cache_add() is atomic on a persistent object cache; the transient + // check below covers sites without one. + if ( wp_cache_add( self::LOCK_KEY, 1, 'procore-connect', self::LOCK_TTL ) ) { + set_transient( self::LOCK_KEY, 1, self::LOCK_TTL ); + + return true; + } + + if ( false !== get_transient( self::LOCK_KEY ) ) { + return false; + } + + set_transient( self::LOCK_KEY, 1, self::LOCK_TTL ); + + return true; + } + + /** + * Release the refresh lock. + * + * @return void + */ + public static function release_lock(): void { + wp_cache_delete( self::LOCK_KEY, 'procore-connect' ); + delete_transient( self::LOCK_KEY ); + } + + /** + * Wait briefly for another process to finish refreshing. + * + * @param int $attempts Number of 250ms polls before giving up. + * @return bool True when a valid token became available. + */ + public static function wait_for_refresh( int $attempts = 8 ): bool { + for ( $i = 0; $i < $attempts; $i++ ) { + usleep( 250000 ); + + if ( self::has_valid_token() ) { + return true; + } + } + + return false; + } +} diff --git a/src/Blocks/Registrar.php b/src/Blocks/Registrar.php new file mode 100644 index 0000000..afb4474 --- /dev/null +++ b/src/Blocks/Registrar.php @@ -0,0 +1,160 @@ + array( $this, 'render' ), + ) + ); + } + + /** + * Render the block by delegating to the matching shortcode. + * + * @param array $attributes Block attributes. + * @return string Rendered markup. + */ + public function render( array $attributes ): string { + $tag = isset( $attributes['shortcode'] ) ? sanitize_key( (string) $attributes['shortcode'] ) : ''; + + if ( '' === $tag || null === ShortcodeRegistrar::get( $tag ) ) { + return ''; + } + + $atts = array(); + $given = isset( $attributes['atts'] ) && is_array( $attributes['atts'] ) ? $attributes['atts'] : array(); + + foreach ( $given as $key => $value ) { + if ( '' === $value || null === $value ) { + continue; + } + + $atts[] = sprintf( '%s="%s"', sanitize_key( (string) $key ), esc_attr( (string) $value ) ); + } + + $shortcode = sprintf( '[%s %s]', $tag, implode( ' ', $atts ) ); + + return do_shortcode( $shortcode ); + } + + /** + * Expose the shortcode registry to the editor script. + * + * @return void + */ + public function localize(): void { + if ( ! Settings::get( 'enable_blocks', true ) ) { + return; + } + + $handle = generate_block_asset_handle( self::BLOCK, 'editorScript' ); + + if ( ! wp_script_is( $handle, 'registered' ) ) { + return; + } + + $variations = array(); + + foreach ( ShortcodeRegistrar::definitions() as $tag => $definition ) { + $handler = new $definition['handler']( $definition ); + + $variations[] = array( + 'name' => str_replace( 'procore_', '', $tag ), + 'shortcode' => $tag, + 'title' => $this->variation_title( $tag, $definition ), + 'description' => (string) $definition['description'], + 'atts' => array_keys( $handler->defaults() ), + ); + } + + wp_localize_script( + $handle, + 'procoreConnectBlocks', + array( + 'variations' => $variations, + 'restUrl' => rest_url( 'procore-connect/v1' ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( \ProcoreConnect\Admin\Ajax::NONCE ), + ) + ); + + wp_set_script_translations( $handle, 'procore-connect', PROCORE_CONNECT_PATH . 'languages' ); + } + + /** + * Build a human readable variation title. + * + * @param string $tag Shortcode tag. + * @param array $definition Shortcode definition. + * @return string Title. + */ + private function variation_title( string $tag, array $definition ): string { + if ( '' !== (string) ( $definition['title'] ?? '' ) ) { + return sprintf( + /* translators: %s: shortcode display name, e.g. "RFIs". */ + __( 'Procore: %s', 'procore-connect' ), + (string) $definition['title'] + ); + } + + return sprintf( + /* translators: %s: shortcode display name. */ + __( 'Procore: %s', 'procore-connect' ), + ucwords( str_replace( array( 'procore_', '_' ), array( '', ' ' ), $tag ) ) + ); + } +} diff --git a/src/Cli/Commands.php b/src/Cli/Commands.php new file mode 100644 index 0000000..d335c87 --- /dev/null +++ b/src/Cli/Commands.php @@ -0,0 +1,294 @@ + $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function test( array $args, array $assoc_args ): void { + $report = ( new ConnectionTester() )->run(); + + $rows = array(); + + foreach ( $report['steps'] as $step ) { + $rows[] = array( + 'check' => $step['label'], + 'result' => $step['ok'] ? 'OK' : 'FAIL', + 'detail' => $step['message'], + ); + } + + \WP_CLI\Utils\format_items( 'table', $rows, array( 'check', 'result', 'detail' ) ); + + if ( ! empty( $report['probes'] ) ) { + \WP_CLI::line( '' ); + \WP_CLI::line( 'Endpoint permissions:' ); + \WP_CLI\Utils\format_items( 'table', $report['probes'], array( 'slug', 'status', 'message', 'permission' ) ); + } + + if ( $report['ok'] ) { + \WP_CLI::success( $report['summary'] ); + + return; + } + + \WP_CLI::warning( $report['summary'] ); + } + + /** + * Purge cached Procore responses. + * + * ## OPTIONS + * + * [--group=] + * : Only purge one endpoint group, e.g. projects. + * + * ## EXAMPLES + * + * wp procore-connect cache-clear + * wp procore-connect cache-clear --group=rfis + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function cache_clear( array $args, array $assoc_args ): void { + $group = isset( $assoc_args['group'] ) ? sanitize_key( $assoc_args['group'] ) : ''; + $removed = Cache::flush( $group ); + + \WP_CLI::success( + sprintf( + /* translators: %d: number of cache entries removed. */ + _n( 'Removed %d cached response.', 'Removed %d cached responses.', $removed, 'procore-connect' ), + $removed + ) + ); + } + + /** + * Refresh cached project data for the default company. + * + * ## EXAMPLES + * + * wp procore-connect cache-warm + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function cache_warm( array $args, array $assoc_args ): void { + $company = Settings::default_company_id(); + + if ( $company <= 0 ) { + \WP_CLI::error( 'No default company is configured.' ); + } + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'all' => true, + 'bypass_cache' => true, + ) + ); + + if ( is_wp_error( $projects ) ) { + \WP_CLI::error( $projects->get_error_message() ); + } + + \WP_CLI::success( sprintf( 'Cached %d projects.', is_array( $projects ) ? count( $projects ) : 0 ) ); + } + + /** + * List the projects visible to the configured credentials. + * + * ## OPTIONS + * + * [--company=] + * : Company ID. Defaults to the configured company. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - count + * --- + * + * ## EXAMPLES + * + * wp procore-connect projects --format=csv + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function projects( array $args, array $assoc_args ): void { + $company = isset( $assoc_args['company'] ) ? absint( $assoc_args['company'] ) : Settings::default_company_id(); + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'all' => true, + ) + ); + + if ( is_wp_error( $projects ) ) { + \WP_CLI::error( $projects->get_error_message() ); + } + + $rows = array(); + + foreach ( (array) $projects as $project ) { + $rows[] = array( + 'id' => absint( Arr::get( $project, 'id', 0 ) ), + 'name' => Arr::str( $project, 'name' ), + 'number' => Arr::str( $project, 'project_number' ), + 'location' => \ProcoreConnect\Support\Format::location( $project ), + 'active' => Arr::get( $project, 'active' ) ? 'yes' : 'no', + ); + } + + \WP_CLI\Utils\format_items( + isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table', + $rows, + array( 'id', 'name', 'number', 'location', 'active' ) + ); + } + + /** + * Discard the stored token so the next request re-authenticates. + * + * ## EXAMPLES + * + * wp procore-connect reset-token + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function reset_token( array $args, array $assoc_args ): void { + TokenStore::clear(); + Client::reset_circuit(); + + \WP_CLI::success( 'Stored tokens cleared.' ); + } + + /** + * Print a summary of the current configuration. + * + * ## EXAMPLES + * + * wp procore-connect doctor + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function doctor( array $args, array $assoc_args ): void { + $limit = Client::rate_limit(); + $circuit = Client::circuit_state(); + + $rows = array( + array( + 'setting' => 'Plugin version', + 'value' => PROCORE_CONNECT_VERSION, + ), + array( + 'setting' => 'Environment', + 'value' => Environment::current(), + ), + array( + 'setting' => 'API host', + 'value' => Environment::api_host(), + ), + array( + 'setting' => 'Login host', + 'value' => Environment::login_host(), + ), + array( + 'setting' => 'Redirect URI', + 'value' => Environment::redirect_uri(), + ), + array( + 'setting' => 'Auth mode', + 'value' => (string) Settings::get( 'auth_mode', '' ), + ), + array( + 'setting' => 'Client ID set', + 'value' => '' !== Settings::client_id() ? 'yes' : 'no', + ), + array( + 'setting' => 'Client secret set', + 'value' => '' !== Settings::client_secret() ? 'yes' : 'no', + ), + array( + 'setting' => 'Default company', + 'value' => (string) Settings::default_company_id(), + ), + array( + 'setting' => 'Cache enabled', + 'value' => Cache::enabled() ? 'yes' : 'no', + ), + array( + 'setting' => 'Cached responses', + 'value' => (string) Cache::stats()['total'], + ), + array( + 'setting' => 'Rate limit', + 'value' => $limit['limit'] > 0 ? $limit['remaining'] . '/' . $limit['limit'] : 'unknown', + ), + array( + 'setting' => 'Circuit breaker', + 'value' => $circuit['open_until'] > time() ? 'open' : 'closed', + ), + ); + + \WP_CLI\Utils\format_items( 'table', $rows, array( 'setting', 'value' ) ); + } +} diff --git a/src/Frontend/Assets.php b/src/Frontend/Assets.php new file mode 100644 index 0000000..8319c54 --- /dev/null +++ b/src/Frontend/Assets.php @@ -0,0 +1,135 @@ +queried_content_uses_plugin() ) { + wp_enqueue_style( self::HANDLE ); + } + } + + /** + * Enqueue the stylesheet late when output appeared after the head was sent. + * + * @return void + */ + public function late_enqueue(): void { + if ( ! self::$needed || ! Settings::get( 'load_styles', true ) ) { + return; + } + + if ( wp_style_is( self::HANDLE, 'enqueued' ) ) { + return; + } + + wp_enqueue_style( self::HANDLE ); + } + + /** + * Whether any queried post contains a Procore Connect shortcode or block. + * + * @return bool True when plugin output is expected. + */ + private function queried_content_uses_plugin(): bool { + global $wp_query; + + if ( ! isset( $wp_query->posts ) || ! is_array( $wp_query->posts ) ) { + return false; + } + + $tags = array_keys( Registrar::definitions() ); + + foreach ( $wp_query->posts as $post ) { + if ( ! isset( $post->post_content ) || ! is_string( $post->post_content ) ) { + continue; + } + + if ( false !== strpos( $post->post_content, 'wp:procore-connect/' ) ) { + return true; + } + + foreach ( $tags as $tag ) { + if ( has_shortcode( $post->post_content, $tag ) ) { + return true; + } + } + } + + return false; + } +} diff --git a/src/Frontend/Renderer.php b/src/Frontend/Renderer.php new file mode 100644 index 0000000..ccb2bd4 --- /dev/null +++ b/src/Frontend/Renderer.php @@ -0,0 +1,120 @@ + $data Variables exposed to the template as `$data`. + * @return string Rendered markup. + */ + public static function render( string $template, array $data = array() ): string { + $path = self::locate( $template ); + + if ( '' === $path ) { + return ''; + } + + /** + * Filters the data passed to a Procore Connect template. + * + * @since 2.0.0 + * + * @param array $data Template data. + * @param string $template Template name. + */ + $data = (array) apply_filters( 'procore_connect_template_data', $data, $template ); + + ob_start(); + + // The template reads from $data. + include $path; + + return (string) ob_get_clean(); + } + + /** + * Resolve a template name to a file path. + * + * Child theme, then parent theme, then the plugin's own defaults. + * + * @param string $template Template name without the `.php` extension. + * @return string Absolute path, or an empty string when no template exists. + */ + public static function locate( string $template ): string { + $template = str_replace( array( '..', "\0" ), '', $template ); + $template = trim( preg_replace( '/[^a-z0-9_\-\/]/i', '', $template ) ?? '', '/' ); + + if ( '' === $template ) { + return ''; + } + + $file = $template . '.php'; + + $candidates = array( + trailingslashit( get_stylesheet_directory() ) . self::THEME_DIR . '/' . $file, + trailingslashit( get_template_directory() ) . self::THEME_DIR . '/' . $file, + PROCORE_CONNECT_PATH . 'templates/' . $file, + ); + + /** + * Filters the candidate paths considered when locating a template. + * + * @since 2.0.0 + * + * @param array $candidates Absolute candidate paths, in priority order. + * @param string $template Template name. + */ + $candidates = (array) apply_filters( 'procore_connect_template_candidates', $candidates, $template ); + + foreach ( $candidates as $candidate ) { + if ( is_readable( $candidate ) ) { + return (string) $candidate; + } + } + + return ''; + } + + /** + * Render a user-facing notice in place of missing or failed content. + * + * The underlying API message is deliberately withheld from the front end: + * Procore errors routinely name accounts, projects and permissions. + * + * @param string $message Public message. + * @param string $type Notice type, used as a modifier class. + * @return string Rendered markup. + */ + public static function notice( string $message, string $type = 'info' ): string { + return sprintf( + '
%2$s
', + esc_attr( sanitize_html_class( $type ) ), + esc_html( $message ) + ); + } +} diff --git a/src/Frontend/Shortcodes/AbstractShortcode.php b/src/Frontend/Shortcodes/AbstractShortcode.php new file mode 100644 index 0000000..4e322c5 --- /dev/null +++ b/src/Frontend/Shortcodes/AbstractShortcode.php @@ -0,0 +1,394 @@ +> + */ + protected $columns = array(); + + /** + * Additional shortcode-specific attribute defaults. + * + * @var array + */ + protected $extra_atts = array(); + + /** + * Heading rendered above the output when `title` is not overridden. + * + * @var string + */ + protected $default_title = ''; + + /** + * Construct the shortcode from a registry definition. + * + * @param array $definition Shortcode definition. + */ + public function __construct( array $definition ) { + $this->tag = (string) ( $definition['tag'] ?? '' ); + $this->endpoint = (string) ( $definition['endpoint'] ?? '' ); + $this->template = (string) ( $definition['template'] ?? 'collection' ); + $this->columns = (array) ( $definition['columns'] ?? array() ); + $this->extra_atts = (array) ( $definition['atts'] ?? array() ); + $this->default_title = (string) ( $definition['title'] ?? '' ); + } + + /** + * The shortcode tag. + * + * @return string Tag. + */ + public function tag(): string { + return $this->tag; + } + + /** + * Render the shortcode. + * + * @param array|string $atts Raw shortcode attributes. + * @param string|null $content Enclosed content, unused. + * @return string Rendered markup. + */ + public function render( $atts, ?string $content = null ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Signature fixed by add_shortcode(). + $atts = shortcode_atts( $this->defaults(), (array) $atts, $this->tag ); + $atts = $this->sanitize_atts( $atts ); + + Assets::mark_needed(); + + $result = $this->resolve( $atts ); + + if ( is_wp_error( $result ) ) { + return $this->error( $result ); + } + + return $this->output( $result, $atts ); + } + + /** + * Attribute defaults, merging the common vocabulary with per-shortcode extras. + * + * @return array Defaults. + */ + public function defaults(): array { + return array_merge( + array( + 'company_id' => '', + 'project_id' => '', + 'id' => '', + 'limit' => 0, + 'page' => 1, + 'orderby' => '', + 'order' => 'asc', + 'columns' => '', + 'fields' => '', + 'template' => '', + 'class' => '', + 'title' => '', + 'cache' => '', + 'empty_text' => '', + 'show_email' => 'false', + 'all' => 'false', + ), + $this->extra_atts + ); + } + + /** + * Sanitize every attribute before it is used. + * + * @param array $atts Raw attributes. + * @return array Sanitized attributes. + */ + protected function sanitize_atts( array $atts ): array { + $clean = array(); + + foreach ( $atts as $key => $value ) { + switch ( $key ) { + case 'company_id': + case 'project_id': + case 'id': + case 'limit': + case 'page': + case 'cache': + case 'width': + case 'height': + case 'max_pages': + $clean[ $key ] = '' === $value ? '' : absint( $value ); + break; + + case 'order': + $clean[ $key ] = 'desc' === strtolower( (string) $value ) ? 'desc' : 'asc'; + break; + + case 'orderby': + case 'endpoint': + $clean[ $key ] = preg_replace( '/[^a-z0-9_.\-]/i', '', (string) $value ) ?? ''; + break; + + case 'columns': + case 'fields': + $clean[ $key ] = preg_replace( '/[^a-z0-9_.,\-]/i', '', (string) $value ) ?? ''; + break; + + case 'template': + $clean[ $key ] = preg_replace( '/[^a-z0-9_\-]/i', '', (string) $value ) ?? ''; + break; + + case 'class': + $clean[ $key ] = Format::classes( '', (string) $value ); + break; + + case 'show_email': + case 'all': + case 'show_details': + case 'active_only': + $clean[ $key ] = in_array( strtolower( (string) $value ), array( 'true', '1', 'yes', 'on' ), true ); + break; + + default: + $clean[ $key ] = sanitize_text_field( (string) $value ); + break; + }//end switch + }//end foreach + + return $clean; + } + + /** + * Retrieve the data this shortcode renders. + * + * @param array $atts Sanitized attributes. + * @return mixed|\WP_Error Response payload, or an error. + */ + protected function resolve( array $atts ) { + if ( '' === $this->endpoint || ! Endpoints::exists( $this->endpoint ) ) { + return new \WP_Error( 'procore_connect_unknown_endpoint', __( 'This shortcode is not bound to a known Procore endpoint.', 'procore-connect' ) ); + } + + return Client::instance()->fetch( $this->endpoint, $this->query_args( $atts ), $this->fetch_options( $atts ) ); + } + + /** + * Query parameters sent with the request. + * + * @param array $atts Sanitized attributes. + * @return array Query parameters. + */ + protected function query_args( array $atts ): array { + $args = array(); + + if ( ! empty( $atts['page'] ) ) { + $args['page'] = (int) $atts['page']; + } + + return $args; + } + + /** + * Client behaviour options derived from the attributes. + * + * @param array $atts Sanitized attributes. + * @return array Options. + */ + protected function fetch_options( array $atts ): array { + $options = array( + 'company_id' => $this->company_id( $atts ), + 'project_id' => $this->project_id( $atts ), + 'all' => ! empty( $atts['all'] ), + ); + + if ( '' !== $atts['cache'] && $atts['cache'] > 0 ) { + $options['ttl'] = Cache::clamp_ttl( (int) $atts['cache'] ); + } + + if ( ! empty( $atts['limit'] ) ) { + $options['per_page'] = min( 2000, max( 1, (int) $atts['limit'] ) ); + } + + return $options; + } + + /** + * Resolve the company identifier for this render. + * + * @param array $atts Sanitized attributes. + * @return int Company identifier. + */ + protected function company_id( array $atts ): int { + $explicit = absint( $atts['company_id'] ); + + return $explicit > 0 ? $explicit : Settings::default_company_id(); + } + + /** + * Resolve the project identifier for this render. + * + * The legacy `id` attribute is accepted as an alias so pages written for + * ProcoreWP 1.x continue to work unchanged. + * + * @param array $atts Sanitized attributes. + * @return int Project identifier. + */ + protected function project_id( array $atts ): int { + $explicit = absint( $atts['project_id'] ); + + if ( $explicit <= 0 ) { + $explicit = absint( $atts['id'] ); + } + + return $explicit > 0 ? $explicit : Settings::default_project_id(); + } + + /** + * Column definitions, honouring a `columns` attribute override. + * + * @param array $atts Sanitized attributes. + * @return array> Column definitions. + */ + protected function resolve_columns( array $atts ): array { + $list = '' !== (string) $atts['columns'] ? (string) $atts['columns'] : (string) $atts['fields']; + $requested = array_filter( array_map( 'trim', explode( ',', $list ) ) ); + + if ( empty( $requested ) ) { + return $this->columns; + } + + $indexed = array(); + + foreach ( $this->columns as $column ) { + $indexed[ $column['key'] ] = $column; + } + + $resolved = array(); + + foreach ( $requested as $key ) { + $resolved[] = $indexed[ $key ] ?? array( + 'key' => $key, + 'label' => Format::label( $key ), + 'format' => 'text', + ); + } + + return $resolved; + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + abstract protected function output( $data, array $atts ): string; + + /** + * The template name for this render. + * + * @param array $atts Sanitized attributes. + * @return string Template name. + */ + protected function template( array $atts ): string { + return '' !== $atts['template'] ? (string) $atts['template'] : $this->template; + } + + /** + * Convert an API failure into safe front-end output. + * + * Administrators see the underlying message; everyone else sees a neutral + * notice, because Procore errors routinely name accounts and permissions. + * + * @param \WP_Error $error Failure. + * @return string Rendered markup. + */ + protected function error( \WP_Error $error ): string { + if ( current_user_can( 'manage_options' ) ) { + return Renderer::notice( + sprintf( + /* translators: %s: error message from the Procore API. */ + __( 'Procore Connect (visible to administrators only): %s', 'procore-connect' ), + $error->get_error_message() + ), + 'error' + ); + } + + return Renderer::notice( __( 'Project information is temporarily unavailable.', 'procore-connect' ), 'error' ); + } + + /** + * The heading rendered above the output. + * + * @param array $atts Sanitized attributes. + * @return string Heading text, or an empty string to omit it. + */ + protected function title( array $atts ): string { + if ( isset( $atts['title'] ) && '' !== $atts['title'] ) { + return '-' === $atts['title'] ? '' : (string) $atts['title']; + } + + return $this->default_title; + } + + /** + * The message shown when the endpoint returns no records. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'No records were found.', 'procore-connect' ); + } +} diff --git a/src/Frontend/Shortcodes/CollectionShortcode.php b/src/Frontend/Shortcodes/CollectionShortcode.php new file mode 100644 index 0000000..ed8f936 --- /dev/null +++ b/src/Frontend/Shortcodes/CollectionShortcode.php @@ -0,0 +1,197 @@ + $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $rows = $this->extract_rows( $data ); + $rows = $this->filter_rows( $rows, $atts ); + + $orderby = $this->orderby( $atts ); + + if ( '' !== $orderby ) { + $rows = Arr::sort_by( $rows, $orderby, $this->order( $atts ) ); + } + + $limit = absint( $atts['limit'] ); + + if ( $limit > 0 && count( $rows ) > $limit ) { + $rows = array_slice( $rows, 0, $limit ); + } + + if ( empty( $rows ) ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + return Renderer::render( + $this->template( $atts ), + array( + 'rows' => $rows, + 'columns' => $this->resolve_columns( $atts ), + 'title' => $this->title( $atts ), + 'class' => Format::classes( 'procore-connect procore-connect-collection procore-connect-' . str_replace( '_', '-', $this->endpoint ), (string) $atts['class'] ), + 'show_email' => ! empty( $atts['show_email'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * The field to sort on, honouring the ProcoreWP 1.x `sort_by` alias. + * + * @param array $atts Sanitized attributes. + * @return string Field path, or an empty string for no sorting. + */ + protected function orderby( array $atts ): string { + if ( '' !== (string) $atts['orderby'] ) { + return (string) $atts['orderby']; + } + + return (string) ( $atts['sort_by'] ?? '' ); + } + + /** + * The sort direction, honouring the ProcoreWP 1.x `sort_order` alias. + * + * @param array $atts Sanitized attributes. + * @return string Either `asc` or `desc`. + */ + protected function order( array $atts ): string { + $legacy = strtolower( (string) ( $atts['sort_order'] ?? '' ) ); + + if ( 'desc' === $legacy ) { + return 'desc'; + } + + if ( 'asc' === $legacy ) { + return 'asc'; + } + + return (string) $atts['order']; + } + + /** + * Column definitions, honouring the ProcoreWP 1.x `show_details` attribute. + * + * In 1.x, `show_details="false"` reduced the project list to ID and name. + * An explicit `false` is still respected so existing pages look the same; + * omitting it now shows the full column set, which is the more useful + * default for a new page. + * + * @param array $atts Sanitized attributes. + * @return array> Column definitions. + */ + protected function resolve_columns( array $atts ): array { + $columns = parent::resolve_columns( $atts ); + + if ( ! array_key_exists( 'show_details', $atts ) || $atts['show_details'] ) { + return $columns; + } + + if ( '' !== (string) $atts['columns'] || '' !== (string) $atts['fields'] ) { + return $columns; + } + + return array_values( + array_filter( + $columns, + static function ( array $column ): bool { + return in_array( (string) $column['key'], array( 'id', 'name' ), true ); + } + ) + ); + } + + /** + * Normalise a response body into a list of records. + * + * Some Procore resources return a bare array, others wrap the list in a + * named key such as `data` or the resource name. + * + * @param mixed $data Response payload. + * @return array Records. + */ + protected function extract_rows( $data ): array { + if ( Arr::is_list( $data ) ) { + return (array) $data; + } + + if ( ! is_array( $data ) ) { + return array(); + } + + foreach ( array( 'data', 'items', $this->endpoint ) as $key ) { + if ( isset( $data[ $key ] ) && Arr::is_list( $data[ $key ] ) ) { + return (array) $data[ $key ]; + } + } + + // A single record returned where a list was expected. + return array( $data ); + } + + /** + * Apply shortcode-level row filtering. + * + * @param array $rows Records. + * @param array $atts Sanitized attributes. + * @return array Filtered records. + */ + protected function filter_rows( array $rows, array $atts ): array { + if ( array_key_exists( 'active_only', $atts ) && $atts['active_only'] ) { + $rows = array_values( + array_filter( + $rows, + static function ( $row ): bool { + $active = Arr::get( $row, 'active' ); + + return null === $active ? true : (bool) $active; + } + ) + ); + } + + if ( ! empty( $atts['status'] ) ) { + $wanted = strtolower( (string) $atts['status'] ); + + $rows = array_values( + array_filter( + $rows, + static function ( $row ) use ( $wanted ): bool { + return strtolower( Arr::str( $row, 'status' ) ) === $wanted; + } + ) + ); + } + + return $rows; + } +} diff --git a/src/Frontend/Shortcodes/DataShortcode.php b/src/Frontend/Shortcodes/DataShortcode.php new file mode 100644 index 0000000..17b1f40 --- /dev/null +++ b/src/Frontend/Shortcodes/DataShortcode.php @@ -0,0 +1,92 @@ + Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'endpoint' => '', + 'status' => '', + ) + ); + } + + /** + * Retrieve the data this shortcode renders. + * + * @param array $atts Sanitized attributes. + * @return mixed|\WP_Error Response payload, or an error. + */ + protected function resolve( array $atts ) { + $slug = (string) $atts['endpoint']; + + if ( '' === $slug ) { + return new \WP_Error( + 'procore_connect_missing_endpoint', + sprintf( + /* translators: %s: comma-separated list of endpoint slugs. */ + __( 'The endpoint attribute is required. Available endpoints: %s', 'procore-connect' ), + implode( ', ', array_keys( Endpoints::public_endpoints() ) ) + ) + ); + } + + if ( ! array_key_exists( $slug, Endpoints::public_endpoints() ) ) { + return new \WP_Error( + 'procore_connect_endpoint_not_public', + sprintf( + /* translators: 1: requested endpoint slug, 2: comma-separated list of endpoint slugs. */ + __( 'The endpoint "%1$s" is not available to shortcodes. Available endpoints: %2$s', 'procore-connect' ), + $slug, + implode( ', ', array_keys( Endpoints::public_endpoints() ) ) + ) + ); + } + + $this->endpoint = $slug; + + $definition = Endpoints::get( $slug ); + + if ( empty( $this->columns ) && null !== $definition ) { + $this->columns = array_map( + static function ( string $field ): array { + return array( + 'key' => $field, + 'label' => Format::label( $field ), + 'format' => 'text', + ); + }, + (array) $definition['fields'] + ); + } + + return parent::resolve( $atts ); + } +} diff --git a/src/Frontend/Shortcodes/FieldShortcode.php b/src/Frontend/Shortcodes/FieldShortcode.php new file mode 100644 index 0000000..87ee223 --- /dev/null +++ b/src/Frontend/Shortcodes/FieldShortcode.php @@ -0,0 +1,183 @@ + Allow-listed field paths. + */ + public static function allowed_fields(): array { + $fields = array( + 'name', + 'display_name', + 'project_number', + 'description', + 'address', + 'city', + 'state_code', + 'zip', + 'country_code', + 'county', + 'latitude', + 'longitude', + 'phone', + 'time_zone', + 'stage', + 'active', + 'start_date', + 'completion_date', + 'actual_start_date', + 'projected_finish_date', + 'total_value', + 'estimated_value', + 'square_feet', + 'store_number', + 'department', + 'project_type.name', + 'office.name', + 'owners_project_id', + 'created_at', + 'updated_at', + ); + + /** + * Filters the fields `[procore_project_data]` may display. + * + * @since 2.0.0 + * + * @param array $fields Allow-listed field paths. + */ + return array_values( array_unique( (array) apply_filters( 'procore_connect_allowed_project_fields', $fields ) ) ); + } + + /** + * Attribute defaults. + * + * @return array Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'field' => '', + 'label' => '', + 'format' => '', + ) + ); + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $field = (string) $atts['field']; + + if ( '' === $field ) { + return $this->error( new \WP_Error( 'procore_connect_missing_field', __( 'The field attribute is required.', 'procore-connect' ) ) ); + } + + if ( ! in_array( $field, self::allowed_fields(), true ) ) { + return $this->error( + new \WP_Error( + 'procore_connect_field_not_allowed', + sprintf( + /* translators: %s: requested field name. */ + __( 'The field "%s" is not available for display. Add it with the procore_connect_allowed_project_fields filter.', 'procore-connect' ), + $field + ) + ) + ); + } + + $raw = Arr::get( $data, $field ); + + if ( null === $raw || '' === $raw ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + $value = $this->format_value( $raw, (string) $atts['format'], $field ); + + return Renderer::render( + $this->template( $atts ), + array( + 'label' => '' !== $atts['label'] ? (string) $atts['label'] : Format::label( $field ), + 'value' => $value, + 'field' => $field, + 'class' => Format::classes( 'procore-connect procore-connect-field', (string) $atts['class'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * Format a raw value for display. + * + * @param mixed $raw Raw value. + * @param string $format Explicit format hint from the shortcode. + * @param string $field Field name, used to infer a format. + * @return string Display value. + */ + private function format_value( $raw, string $format, string $field ): string { + if ( '' === $format ) { + if ( preg_match( '/(_date|_at)$/', $field ) ) { + $format = 'date'; + } elseif ( preg_match( '/(value|budget|total|amount)$/', $field ) ) { + $format = 'currency'; + } + } + + switch ( $format ) { + case 'date': + return Format::date( $raw ); + + case 'currency': + return Format::currency( $raw ); + + case 'number': + return is_numeric( $raw ) ? number_format_i18n( (float) $raw ) : Arr::stringify( $raw ); + + default: + return Arr::stringify( $raw ); + } + } + + /** + * The empty-state message for a missing field value. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'Not available.', 'procore-connect' ); + } +} diff --git a/src/Frontend/Shortcodes/ImageShortcode.php b/src/Frontend/Shortcodes/ImageShortcode.php new file mode 100644 index 0000000..732d7b7 --- /dev/null +++ b/src/Frontend/Shortcodes/ImageShortcode.php @@ -0,0 +1,106 @@ + + */ + private const IMAGE_KEYS = array( 'logo_url', 'photo_url', 'image_url', 'thumbnail_url', 'logo.url' ); + + /** + * Attribute defaults. + * + * @return array Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'width' => 300, + 'height' => '', + 'alt' => '', + 'lazy' => 'true', + ) + ); + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $url = ''; + + foreach ( self::IMAGE_KEYS as $key ) { + $candidate = Arr::str( $data, $key ); + + if ( '' !== $candidate ) { + $url = $candidate; + break; + } + } + + $url = esc_url_raw( $url, array( 'https' ) ); + + if ( '' === $url ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + $alt = '' !== $atts['alt'] + ? (string) $atts['alt'] + : sprintf( + /* translators: %s: project name. */ + __( '%s project image', 'procore-connect' ), + Arr::str( $data, 'name', __( 'Procore', 'procore-connect' ) ) + ); + + return Renderer::render( + $this->template( $atts ), + array( + 'url' => $url, + 'alt' => $alt, + 'width' => absint( $atts['width'] ), + 'height' => absint( $atts['height'] ), + 'lazy' => in_array( strtolower( (string) $atts['lazy'] ), array( 'true', '1', 'yes', 'on' ), true ), + 'class' => Format::classes( 'procore-connect procore-connect-image', (string) $atts['class'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * The empty-state message when no image is available. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'No project image is available.', 'procore-connect' ); + } +} diff --git a/src/Frontend/Shortcodes/MapShortcode.php b/src/Frontend/Shortcodes/MapShortcode.php new file mode 100644 index 0000000..d044578 --- /dev/null +++ b/src/Frontend/Shortcodes/MapShortcode.php @@ -0,0 +1,107 @@ + Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'active_only' => 'true', + 'link' => 'true', + ) + ); + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $rows = $this->filter_rows( $this->extract_rows( $data ), $atts ); + $points = array(); + + foreach ( $rows as $row ) { + $latitude = Arr::get( $row, 'latitude' ); + $longitude = Arr::get( $row, 'longitude' ); + + if ( ! is_numeric( $latitude ) || ! is_numeric( $longitude ) ) { + continue; + } + + $points[] = array( + 'id' => absint( Arr::get( $row, 'id', 0 ) ), + 'name' => Arr::str( $row, 'name' ), + 'location' => Format::location( $row ), + 'latitude' => round( (float) $latitude, 6 ), + 'longitude' => round( (float) $longitude, 6 ), + ); + } + + $limit = absint( $atts['limit'] ); + + if ( $limit > 0 && count( $points ) > $limit ) { + $points = array_slice( $points, 0, $limit ); + } + + if ( empty( $points ) ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + return Renderer::render( + $this->template( $atts ), + array( + 'points' => $points, + 'title' => $this->title( $atts ), + 'link' => in_array( strtolower( (string) $atts['link'] ), array( 'true', '1', 'yes', 'on' ), true ), + 'class' => Format::classes( 'procore-connect procore-connect-map', (string) $atts['class'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * The empty-state message when no project has coordinates. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'No project locations are available.', 'procore-connect' ); + } +} diff --git a/src/Frontend/Shortcodes/RecordShortcode.php b/src/Frontend/Shortcodes/RecordShortcode.php new file mode 100644 index 0000000..a29d980 --- /dev/null +++ b/src/Frontend/Shortcodes/RecordShortcode.php @@ -0,0 +1,47 @@ + $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + if ( ! is_array( $data ) || empty( $data ) ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + return Renderer::render( + $this->template( $atts ), + array( + 'record' => $data, + 'columns' => $this->resolve_columns( $atts ), + 'title' => '' !== $this->title( $atts ) ? $this->title( $atts ) : (string) ( $data['name'] ?? '' ), + 'class' => Format::classes( 'procore-connect procore-connect-record procore-connect-' . str_replace( '_', '-', $this->endpoint ), (string) $atts['class'] ), + 'show_email' => ! empty( $atts['show_email'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } +} diff --git a/src/Frontend/Shortcodes/Registrar.php b/src/Frontend/Shortcodes/Registrar.php new file mode 100644 index 0000000..7d8587a --- /dev/null +++ b/src/Frontend/Shortcodes/Registrar.php @@ -0,0 +1,550 @@ +>|null + */ + private static $definitions = null; + + /** + * Register every shortcode with WordPress. + * + * @return void + */ + public function register(): void { + foreach ( self::definitions() as $tag => $definition ) { + $handler = $definition['handler']; + $instance = new $handler( $definition ); + + add_shortcode( $tag, array( $instance, 'render' ) ); + } + } + + /** + * All shortcode definitions, keyed by tag. + * + * @return array> Definitions. + */ + public static function definitions(): array { + if ( null !== self::$definitions ) { + return self::$definitions; + } + + $definitions = array( + + /* ---- Preserved from ProcoreWP 1.x ---- */ + + 'procore_project_list' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'projects', + 'template' => 'collection', + 'title' => __( 'Projects', 'procore-connect' ), + 'description' => __( 'A table of Procore projects for a company.', 'procore-connect' ), + // show_details, sort_by and sort_order are ProcoreWP 1.x aliases, + // still honoured so existing pages render the same way. + 'atts' => array( + 'active_only' => 'true', + 'show_details' => 'true', + 'sort_by' => '', + 'sort_order' => '', + ), + 'columns' => array( + array( + 'key' => 'id', + 'label' => __( 'ID', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'name', + 'label' => __( 'Project', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'project_number', + 'label' => __( 'Number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procore-connect' ), + 'format' => 'location', + ), + array( + 'key' => 'active', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'status', + ), + ), + ), + 'procore_project' => array( + 'handler' => RecordShortcode::class, + 'endpoint' => 'project', + 'template' => 'record', + 'description' => __( 'Detail panel for a single Procore project.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'address', + 'label' => __( 'Address', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procore-connect' ), + 'format' => 'location', + ), + array( + 'key' => 'zip', + 'label' => __( 'Postcode', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'project_number', + 'label' => __( 'Project number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'stage', + 'label' => __( 'Stage', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'start_date', + 'label' => __( 'Start date', 'procore-connect' ), + 'format' => 'date', + ), + array( + 'key' => 'completion_date', + 'label' => __( 'Completion date', 'procore-connect' ), + 'format' => 'date', + ), + array( + 'key' => 'active', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'status', + ), + ), + ), + 'procore_team' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'project_users', + 'template' => 'collection', + 'title' => __( 'Project team', 'procore-connect' ), + 'description' => __( 'Team members assigned to a project. Email addresses are hidden unless explicitly enabled.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Name', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'job_title', + 'label' => __( 'Role', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'vendor.name', + 'label' => __( 'Company', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'email_address', + 'label' => __( 'Email', 'procore-connect' ), + 'format' => 'email', + ), + ), + ), + 'procore_featured_image' => array( + 'handler' => ImageShortcode::class, + 'endpoint' => 'project', + 'template' => 'image', + 'description' => __( 'The logo or featured image for a project.', 'procore-connect' ), + ), + 'procore_drawings' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'drawing_areas', + 'template' => 'collection', + 'title' => __( 'Drawings', 'procore-connect' ), + 'description' => __( 'Drawing areas published for a project.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Name', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'description', + 'label' => __( 'Description', 'procore-connect' ), + 'format' => 'text', + ), + ), + ), + 'procore_specifications' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'specification_sections', + 'template' => 'collection', + 'title' => __( 'Specifications', 'procore-connect' ), + 'description' => __( 'Specification sections published for a project.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'description', + 'label' => __( 'Description', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'revision', + 'label' => __( 'Revision', 'procore-connect' ), + 'format' => 'text', + ), + ), + ), + 'procore_project_data' => array( + 'handler' => FieldShortcode::class, + 'endpoint' => 'project', + 'template' => 'field', + 'description' => __( 'A single allow-listed field from a project record.', 'procore-connect' ), + ), + + /* ---- Project management tools ---- */ + + 'procore_rfis' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'rfis', + 'template' => 'collection', + 'title' => __( 'RFIs', 'procore-connect' ), + 'description' => __( 'Requests for information raised on a project.', 'procore-connect' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'subject', + 'label' => __( 'Subject', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procore-connect' ), + 'format' => 'date', + ), + ), + ), + 'procore_submittals' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'submittals', + 'template' => 'collection', + 'title' => __( 'Submittals', 'procore-connect' ), + 'description' => __( 'Submittals tracked on a project.', 'procore-connect' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'title', + 'label' => __( 'Title', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procore-connect' ), + 'format' => 'date', + ), + ), + ), + 'procore_punch_list' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'punch_items', + 'template' => 'collection', + 'title' => __( 'Punch list', 'procore-connect' ), + 'description' => __( 'Outstanding punch list items for a project.', 'procore-connect' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'position', + 'label' => __( 'Item', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'name', + 'label' => __( 'Description', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procore-connect' ), + 'format' => 'date', + ), + ), + ), + 'procore_observations' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'observations', + 'template' => 'collection', + 'title' => __( 'Observations', 'procore-connect' ), + 'description' => __( 'Quality and safety observations recorded on a project.', 'procore-connect' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'name', + 'label' => __( 'Observation', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procore-connect' ), + 'format' => 'date', + ), + ), + ), + 'procore_daily_logs' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'daily_logs', + 'template' => 'collection', + 'title' => __( 'Daily logs', 'procore-connect' ), + 'description' => __( 'Daily construction report logs for a project.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'date', + 'label' => __( 'Date', 'procore-connect' ), + 'format' => 'date', + ), + array( + 'key' => 'comments', + 'label' => __( 'Notes', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'text', + ), + ), + ), + 'procore_change_orders' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'change_orders', + 'template' => 'collection', + 'title' => __( 'Change orders', 'procore-connect' ), + 'description' => __( 'Change order packages raised on a project.', 'procore-connect' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'title', + 'label' => __( 'Title', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'grand_total', + 'label' => __( 'Value', 'procore-connect' ), + 'format' => 'currency', + ), + ), + ), + 'procore_milestones' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'milestones', + 'template' => 'collection', + 'title' => __( 'Schedule', 'procore-connect' ), + 'description' => __( 'Schedule tasks and milestones for a project.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Task', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'start_date', + 'label' => __( 'Start', 'procore-connect' ), + 'format' => 'date', + ), + array( + 'key' => 'finish_date', + 'label' => __( 'Finish', 'procore-connect' ), + 'format' => 'date', + ), + array( + 'key' => 'percent_complete', + 'label' => __( 'Complete', 'procore-connect' ), + 'format' => 'percent', + ), + ), + ), + + /* ---- Company and directory ---- */ + + 'procore_vendors' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'company_vendors', + 'template' => 'collection', + 'title' => __( 'Vendors', 'procore-connect' ), + 'description' => __( 'Companies in the Procore company directory.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Company', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procore-connect' ), + 'format' => 'location', + ), + array( + 'key' => 'business_phone', + 'label' => __( 'Phone', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'website', + 'label' => __( 'Website', 'procore-connect' ), + 'format' => 'url', + ), + ), + ), + 'procore_offices' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'offices', + 'template' => 'collection', + 'title' => __( 'Offices', 'procore-connect' ), + 'description' => __( 'Offices registered against the Procore company.', 'procore-connect' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Office', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => 'address', + 'label' => __( 'Address', 'procore-connect' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procore-connect' ), + 'format' => 'location', + ), + array( + 'key' => 'phone', + 'label' => __( 'Phone', 'procore-connect' ), + 'format' => 'text', + ), + ), + ), + 'procore_project_map' => array( + 'handler' => MapShortcode::class, + 'endpoint' => 'projects', + 'template' => 'map', + 'title' => __( 'Project locations', 'procore-connect' ), + 'description' => __( 'Projects that have coordinates, as an accessible location list with geo microdata.', 'procore-connect' ), + ), + + /* ---- Generic ---- */ + + 'procore_data' => array( + 'handler' => DataShortcode::class, + 'endpoint' => '', + 'template' => 'collection', + 'description' => __( 'Render any endpoint published in the Procore endpoint registry.', 'procore-connect' ), + ), + ); + + /** + * Filters the shortcode registry. + * + * @since 2.0.0 + * + * @param array> $definitions Shortcode definitions keyed by tag. + */ + $definitions = (array) apply_filters( 'procore_connect_shortcodes', $definitions ); + + foreach ( $definitions as $tag => $definition ) { + $definitions[ $tag ]['tag'] = $tag; + } + + self::$definitions = $definitions; + + return self::$definitions; + } + + /** + * Retrieve a single definition. + * + * @param string $tag Shortcode tag. + * @return array|null Definition, or null when unregistered. + */ + public static function get( string $tag ): ?array { + $definitions = self::definitions(); + + return $definitions[ $tag ] ?? null; + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..6e2731c --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,194 @@ +boot(); + } + + return self::$instance; + } + + /** + * Register hooks. + * + * @return void + */ + private function boot(): void { + add_action( 'init', array( $this, 'maybe_upgrade' ) ); + + ( new ShortcodeRegistrar() )->register(); + ( new Assets() )->register(); + + if ( is_admin() ) { + ( new SettingsPage() )->register(); + ( new Ajax() )->register(); + ( new Notices() )->register(); + } + + ( new OAuthController() )->register(); + + if ( Settings::get( 'enable_blocks', true ) ) { + ( new BlockRegistrar() )->register(); + } + + if ( Settings::get( 'enable_rest', false ) ) { + ( new RestController() )->register(); + } + + add_action( self::CRON_HOOK, array( $this, 'warm_cache' ) ); + add_filter( 'plugin_action_links_' . PROCORE_CONNECT_BASENAME, array( $this, 'action_links' ) ); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + Commands::register(); + } + } + + /* + * No load_plugin_textdomain() call: WordPress has loaded translations + * just-in-time since 4.6 for any plugin whose text domain matches its + * directory name, and calling it explicitly is now flagged by Plugin Check. + */ + + /** + * Run upgrade routines when the stored version is behind the running one. + * + * @return void + */ + public function maybe_upgrade(): void { + $installed = (string) get_option( self::VERSION_OPTION, '' ); + + if ( PROCORE_CONNECT_VERSION === $installed ) { + return; + } + + if ( '' === $installed ) { + Settings::migrate_legacy(); + } + + if ( '' !== $installed && version_compare( $installed, '2.0.0', '<' ) ) { + Cache::flush(); + } + + update_option( self::VERSION_OPTION, PROCORE_CONNECT_VERSION, false ); + } + + /** + * Refresh cached responses for the configured default company. + * + * @return void + */ + public function warm_cache(): void { + $company = Settings::default_company_id(); + + if ( $company <= 0 ) { + return; + } + + Api\Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'bypass_cache' => true, + ) + ); + } + + /** + * Add a Settings link to the plugins list row. + * + * @param array $links Existing action links. + * @return array Modified action links. + */ + public function action_links( array $links ): array { + $settings = sprintf( + '%s', + esc_url( admin_url( 'admin.php?page=procore-connect' ) ), + esc_html__( 'Settings', 'procore-connect' ) + ); + + array_unshift( $links, $settings ); + + return $links; + } + + /** + * Activation handler. + * + * @return void + */ + public static function activate(): void { + Settings::migrate_legacy(); + + if ( false === get_option( Settings::OPTION ) ) { + add_option( Settings::OPTION, Settings::defaults(), '', false ); + } + + update_option( self::VERSION_OPTION, PROCORE_CONNECT_VERSION, false ); + + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK ); + } + } + + /** + * Deactivation handler. + * + * @return void + */ + public static function deactivate(): void { + wp_clear_scheduled_hook( self::CRON_HOOK ); + Cache::flush(); + } +} diff --git a/src/Rest/Controller.php b/src/Rest/Controller.php new file mode 100644 index 0000000..810f11e --- /dev/null +++ b/src/Rest/Controller.php @@ -0,0 +1,211 @@ + \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_endpoints' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/data/(?P[a-z0-9_]+)', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_data' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'endpoint' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => static function ( $value ): bool { + return array_key_exists( (string) $value, Endpoints::public_endpoints() ); + }, + ), + 'company_id' => array( + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + 'project_id' => array( + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + 'per_page' => array( + 'type' => 'integer', + 'default' => 100, + 'minimum' => 1, + 'maximum' => 2000, + 'sanitize_callback' => 'absint', + ), + 'page' => array( + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * List the endpoints this proxy will serve. + * + * @return \WP_REST_Response Endpoint metadata. + */ + public function get_endpoints(): \WP_REST_Response { + $endpoints = array(); + + foreach ( Endpoints::public_endpoints() as $slug => $definition ) { + $endpoints[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'scope' => (string) $definition['scope'], + 'fields' => (array) $definition['fields'], + ); + } + + return rest_ensure_response( array( 'endpoints' => $endpoints ) ); + } + + /** + * Serve a cached endpoint payload. + * + * @param \WP_REST_Request $request Incoming request. + * @return \WP_REST_Response|\WP_Error Response payload, or an error. + */ + public function get_data( \WP_REST_Request $request ) { + $client = Client::instance(); + + $data = $client->fetch( + (string) $request->get_param( 'endpoint' ), + array( 'page' => (int) $request->get_param( 'page' ) ), + array( + 'company_id' => (int) $request->get_param( 'company_id' ), + 'project_id' => (int) $request->get_param( 'project_id' ), + 'per_page' => (int) $request->get_param( 'per_page' ), + ) + ); + + if ( is_wp_error( $data ) ) { + $status = (int) ( $data->get_error_data()['status'] ?? 502 ); + + return new \WP_Error( + $data->get_error_code(), + $this->public_message( $data ), + array( 'status' => $status >= 400 && $status < 600 ? $status : 502 ) + ); + } + + $meta = $client->meta(); + + $response = rest_ensure_response( + array( + 'endpoint' => (string) $request->get_param( 'endpoint' ), + 'cached' => (bool) $meta['cached'], + 'stale' => (bool) $meta['stale'], + 'total' => $meta['total'], + 'data' => $data, + ) + ); + + $response->header( 'X-Procore Connect-Cached', $meta['cached'] ? '1' : '0' ); + + return $response; + } + + /** + * Whether the current request may use the proxy. + * + * @return bool|\WP_Error True when permitted, or an error. + */ + public function check_permission() { + if ( ! Settings::get( 'enable_rest', false ) ) { + return new \WP_Error( + 'procore_connect_rest_disabled', + __( 'The Procore Connect REST proxy is disabled.', 'procore-connect' ), + array( 'status' => 404 ) + ); + } + + $level = (string) Settings::get( 'rest_access', 'logged_in' ); + + if ( 'public' === $level ) { + return true; + } + + if ( 'editor' === $level ) { + return current_user_can( 'edit_posts' ); + } + + return is_user_logged_in(); + } + + /** + * Reduce an internal error to something safe to return over HTTP. + * + * Administrators receive the underlying detail; everyone else does not, + * because Procore error messages routinely name accounts and permissions. + * + * @param \WP_Error $error Internal error. + * @return string Message. + */ + private function public_message( \WP_Error $error ): string { + if ( current_user_can( 'manage_options' ) ) { + return $error->get_error_message(); + } + + return __( 'Project information is temporarily unavailable.', 'procore-connect' ); + } +} diff --git a/src/Support/Arr.php b/src/Support/Arr.php new file mode 100644 index 0000000..96a0b35 --- /dev/null +++ b/src/Support/Arr.php @@ -0,0 +1,208 @@ +$segment ) ) { + $current = $current->$segment; + continue; + } + + return $default_value; + } + + return $current; + } + + /** + * Read a value and coerce it to a display-ready string. + * + * @param mixed $subject Array or object to read from. + * @param string $path Dot-delimited path. + * @param string $default_value Value returned when the path is missing or empty. + * @return string Display string. + */ + public static function str( $subject, string $path, string $default_value = '' ): string { + $value = self::get( $subject, $path ); + + return self::stringify( $value, $default_value ); + } + + /** + * Convert an arbitrary API value into a display string. + * + * @param mixed $value Raw value. + * @param string $default_value Value returned when empty. + * @return string Display string. + */ + public static function stringify( $value, string $default_value = '' ): string { + if ( null === $value || '' === $value ) { + return $default_value; + } + + if ( is_bool( $value ) ) { + return $value ? __( 'Yes', 'procore-connect' ) : __( 'No', 'procore-connect' ); + } + + if ( is_scalar( $value ) ) { + return (string) $value; + } + + if ( is_array( $value ) ) { + // Common Procore shape: a related record exposed as an object with a label. + foreach ( array( 'name', 'label', 'title', 'full_name', 'login' ) as $label_key ) { + if ( isset( $value[ $label_key ] ) && is_scalar( $value[ $label_key ] ) ) { + return (string) $value[ $label_key ]; + } + } + + $parts = array(); + + foreach ( $value as $item ) { + $rendered = self::stringify( $item ); + + if ( '' !== $rendered ) { + $parts[] = $rendered; + } + } + + return empty( $parts ) ? $default_value : implode( ', ', $parts ); + } + + return $default_value; + } + + /** + * Sort a list of associative rows by a column, tolerating missing keys. + * + * `array_multisort()` on `array_column()` output — the approach used by + * ProcoreWP 1.x — silently reorders rows when any record lacks the column. + * + * Records with no value for the column always sort last, in both + * directions, so reversing the order never promotes empty rows to the top. + * + * @param array $rows Rows to sort. + * @param string $column Column key, dot notation supported. + * @param string $direction `asc` or `desc`. + * @return array Sorted rows. + */ + public static function sort_by( array $rows, string $column, string $direction = 'asc' ): array { + if ( '' === $column ) { + return $rows; + } + + $descending = 'desc' === strtolower( $direction ); + + usort( + $rows, + static function ( $a, $b ) use ( $column, $descending ) { + $left = self::get( $a, $column ); + $right = self::get( $b, $column ); + + $left_empty = ( null === $left || '' === $left ); + $right_empty = ( null === $right || '' === $right ); + + if ( $left_empty || $right_empty ) { + if ( $left_empty && $right_empty ) { + return 0; + } + + return $left_empty ? 1 : -1; + } + + if ( is_numeric( $left ) && is_numeric( $right ) ) { + $result = ( (float) $left ) <=> ( (float) $right ); + } else { + $result = strcasecmp( self::stringify( $left ), self::stringify( $right ) ); + } + + return $descending ? -$result : $result; + } + ); + + return $rows; + } + + /** + * Reduce each row to an allow-listed set of fields. + * + * @param array $rows Rows to project. + * @param array $fields Field paths to retain. + * @return array> Projected rows. + */ + public static function pluck_fields( array $rows, array $fields ): array { + if ( empty( $fields ) ) { + return $rows; + } + + $projected = array(); + + foreach ( $rows as $row ) { + $item = array(); + + foreach ( $fields as $field ) { + $item[ $field ] = self::get( $row, $field ); + } + + $projected[] = $item; + } + + return $projected; + } + + /** + * Determine whether a value looks like a list of records. + * + * @param mixed $value Value to test. + * @return bool True for a zero-indexed array. + */ + public static function is_list( $value ): bool { + if ( ! is_array( $value ) ) { + return false; + } + + if ( array() === $value ) { + return true; + } + + return array_keys( $value ) === range( 0, count( $value ) - 1 ); + } +} diff --git a/src/Support/Encryption.php b/src/Support/Encryption.php new file mode 100644 index 0000000..0629981 --- /dev/null +++ b/src/Support/Encryption.php @@ -0,0 +1,193 @@ + $column Column definition with `key` and `format`. + * @param bool $show_email Whether the shortcode opted in to email output. + * @return string Escaped HTML. + */ + public static function cell( $record, array $column, bool $show_email = false ): string { + $key = (string) ( $column['key'] ?? '' ); + $format = (string) ( $column['format'] ?? 'text' ); + + if ( '__location' === $key ) { + return esc_html( self::location( $record ) ); + } + + $raw = Arr::get( $record, $key ); + + switch ( $format ) { + case 'date': + return esc_html( self::date( $raw, '—' ) ); + + case 'currency': + return esc_html( self::currency( $raw, '—' ) ); + + case 'percent': + return is_numeric( $raw ) + ? esc_html( number_format_i18n( (float) $raw, 0 ) . '%' ) + : '—'; + + case 'status': + $active = null === $raw ? null : (bool) $raw; + + if ( null === $active ) { + return '—'; + } + + return sprintf( + '%2$s', + $active ? 'active' : 'inactive', + esc_html( $active ? __( 'Active', 'procore-connect' ) : __( 'Inactive', 'procore-connect' ) ) + ); + + case 'email': + $email = self::email( $raw, $show_email ); + + if ( '' === $email ) { + return ''; + } + + return sprintf( + '%2$s', + esc_url( 'mailto:' . $email ), + esc_html( $email ) + ); + + case 'url': + $url = esc_url_raw( Arr::stringify( $raw ), array( 'http', 'https' ) ); + + if ( '' === $url ) { + return '—'; + } + + $host = wp_parse_url( $url, PHP_URL_HOST ); + + return sprintf( + '%2$s', + esc_url( $url ), + esc_html( is_string( $host ) && '' !== $host ? $host : $url ) + ); + + default: + $value = Arr::stringify( $raw ); + + return '' === $value ? '—' : esc_html( $value ); + }//end switch + } + + /** + * Compose a CSS class attribute from a base class and user-supplied extras. + * + * @param string $base Base class list. + * @param string $extra Additional classes from a shortcode attribute. + * @return string Sanitized class list. + */ + public static function classes( string $base, string $extra = '' ): string { + $classes = preg_split( '/\s+/', $base . ' ' . $extra, -1, PREG_SPLIT_NO_EMPTY ); + + if ( ! is_array( $classes ) ) { + return $base; + } + + $classes = array_map( 'sanitize_html_class', $classes ); + $classes = array_filter( array_unique( $classes ) ); + + return implode( ' ', $classes ); + } +} diff --git a/src/Support/Logger.php b/src/Support/Logger.php new file mode 100644 index 0000000..549bea4 --- /dev/null +++ b/src/Support/Logger.php @@ -0,0 +1,155 @@ + $context Additional context. + * @return void + */ + public static function error( string $message, array $context = array() ): void { + self::log( 'error', $message, $context ); + } + + /** + * Record a warning-level event. + * + * @param string $message Human readable message. + * @param array $context Additional context. + * @return void + */ + public static function warning( string $message, array $context = array() ): void { + self::log( 'warning', $message, $context ); + } + + /** + * Record an informational event. + * + * @param string $message Human readable message. + * @param array $context Additional context. + * @return void + */ + public static function info( string $message, array $context = array() ): void { + self::log( 'info', $message, $context ); + } + + /** + * Retrieve the retained log entries, newest first. + * + * @return array> Log entries. + */ + public static function entries(): array { + $entries = get_option( self::OPTION, array() ); + + return is_array( $entries ) ? $entries : array(); + } + + /** + * Discard all retained log entries. + * + * @return void + */ + public static function clear(): void { + delete_option( self::OPTION ); + } + + /** + * Append an entry to the ring buffer and, when enabled, the debug log. + * + * @param string $level Severity level. + * @param string $message Human readable message. + * @param array $context Additional context. + * @return void + */ + private static function log( string $level, string $message, array $context ): void { + if ( ! Settings::get( 'enable_logging', false ) ) { + return; + } + + $entry = array( + 'time' => time(), + 'level' => $level, + 'message' => $message, + 'context' => self::redact( $context ), + ); + + $entries = self::entries(); + array_unshift( $entries, $entry ); + $entries = array_slice( $entries, 0, self::MAX_ENTRIES ); + + update_option( self::OPTION, $entries, false ); + + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + $suffix = empty( $entry['context'] ) ? '' : ' ' . wp_json_encode( $entry['context'] ); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG_LOG and an explicit opt-in setting. + error_log( sprintf( '[Procore Connect][%s] %s%s', $level, $message, $suffix ) ); + } + } + + /** + * Strip anything that resembles a credential from a context array. + * + * @param array $context Raw context. + * @return array Redacted context. + */ + private static function redact( array $context ): array { + $sensitive = array( 'client_secret', 'access_token', 'refresh_token', 'authorization', 'code', 'secret', 'password' ); + $clean = array(); + + foreach ( $context as $key => $value ) { + $normalised = strtolower( (string) $key ); + + if ( in_array( $normalised, $sensitive, true ) ) { + $clean[ $key ] = '[redacted]'; + continue; + } + + if ( is_array( $value ) ) { + $clean[ $key ] = self::redact( $value ); + continue; + } + + if ( is_scalar( $value ) || null === $value ) { + $clean[ $key ] = is_string( $value ) ? mb_substr( $value, 0, 500 ) : $value; + continue; + } + + $clean[ $key ] = '[object]'; + } + + return $clean; + } +} diff --git a/templates/collection.php b/templates/collection.php new file mode 100644 index 0000000..d6ebb36 --- /dev/null +++ b/templates/collection.php @@ -0,0 +1,61 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +use ProcoreConnect\Support\Format; + +defined( 'ABSPATH' ) || exit; + +$procore_connect_rows = (array) ( $data['rows'] ?? array() ); +$procore_connect_columns = (array) ( $data['columns'] ?? array() ); +$procore_connect_title = (string) ( $data['title'] ?? '' ); +$procore_connect_class = (string) ( $data['class'] ?? 'procore-connect' ); +$procore_connect_show_email = ! empty( $data['show_email'] ); + +if ( empty( $procore_connect_columns ) ) { + return; +} +?> +
+ +

+ + +
+ + + + + + + + + + + + + + + + + +
+ +
+
+
diff --git a/templates/field.php b/templates/field.php new file mode 100644 index 0000000..24e527c --- /dev/null +++ b/templates/field.php @@ -0,0 +1,30 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +defined( 'ABSPATH' ) || exit; + +$procore_connect_label = (string) ( $data['label'] ?? '' ); +$procore_connect_value = (string) ( $data['value'] ?? '' ); +$procore_connect_field = (string) ( $data['field'] ?? '' ); +$procore_connect_class = (string) ( $data['class'] ?? 'procore-connect' ); + +if ( '' === $procore_connect_value ) { + return; +} +?> +
+ + + + +
diff --git a/templates/image.php b/templates/image.php new file mode 100644 index 0000000..bcd8df4 --- /dev/null +++ b/templates/image.php @@ -0,0 +1,42 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +defined( 'ABSPATH' ) || exit; + +$procore_connect_url = (string) ( $data['url'] ?? '' ); +$procore_connect_alt = (string) ( $data['alt'] ?? '' ); +$procore_connect_width = (int) ( $data['width'] ?? 0 ); +$procore_connect_height = (int) ( $data['height'] ?? 0 ); +$procore_connect_lazy = ! empty( $data['lazy'] ); +$procore_connect_class = (string) ( $data['class'] ?? 'procore-connect' ); + +if ( '' === $procore_connect_url ) { + return; +} +?> +
+ <?php echo esc_attr( $procore_connect_alt ); ?> 0 ) : ?> + width="" + + 0 ) : ?> + height="" + + + loading="lazy" decoding="async" + + referrerpolicy="no-referrer" + /> +
diff --git a/templates/map.php b/templates/map.php new file mode 100644 index 0000000..493b04d --- /dev/null +++ b/templates/map.php @@ -0,0 +1,65 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +defined( 'ABSPATH' ) || exit; + +$procore_connect_points = (array) ( $data['points'] ?? array() ); +$procore_connect_title = (string) ( $data['title'] ?? '' ); +$procore_connect_link = ! empty( $data['link'] ); +$procore_connect_class = (string) ( $data['class'] ?? 'procore-connect' ); + +if ( empty( $procore_connect_points ) ) { + return; +} +?> +
+ +

+ + +
    + +
  • + + + + + + + + + + + +
  • + +
+
diff --git a/templates/record.php b/templates/record.php new file mode 100644 index 0000000..a6314b7 --- /dev/null +++ b/templates/record.php @@ -0,0 +1,45 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +use ProcoreConnect\Support\Format; + +defined( 'ABSPATH' ) || exit; + +$procore_connect_record = (array) ( $data['record'] ?? array() ); +$procore_connect_columns = (array) ( $data['columns'] ?? array() ); +$procore_connect_title = (string) ( $data['title'] ?? '' ); +$procore_connect_class = (string) ( $data['class'] ?? 'procore-connect' ); +$procore_connect_show_email = ! empty( $data['show_email'] ); + +if ( empty( $procore_connect_record ) ) { + return; +} +?> +
+ +

+ + +
+ + + + + +
+
+
+
+ +
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..adee824 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,45 @@ +client_returning( array( $this->response( array( array( 'id' => 1 ) ) ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 4242 ) ); + + $this->assertSame( '4242', $calls[0]['args']['headers']['Procore-Company-Id'] ); + $this->assertSame( 'Bearer test-token', $calls[0]['args']['headers']['Authorization'] ); + } + + /** + * Requests must go to the API host, never the login host. + * + * @return void + */ + public function test_requests_target_the_api_host(): void { + $calls = array(); + $client = $this->client_returning( array( $this->response( array() ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertStringStartsWith( 'https://api.procore.com/rest/', $calls[0]['url'] ); + } + + /** + * A timeout and an identifying user agent must always be set. + * + * @return void + */ + public function test_sets_timeout_and_user_agent(): void { + $calls = array(); + $client = $this->client_returning( array( $this->response( array() ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertSame( 15, $calls[0]['args']['timeout'] ); + $this->assertTrue( $calls[0]['args']['sslverify'] ); + $this->assertStringContainsString( 'Procore Connect/2.0.0', $calls[0]['args']['user-agent'] ); + } + + /** + * `all` must follow the Link header rather than guessing page numbers. + * + * @return void + */ + public function test_follows_link_header_for_pagination(): void { + $calls = array(); + $client = $this->client_returning( + array( + $this->response( + array( array( 'id' => 1 ), array( 'id' => 2 ) ), + 200, + array( 'Link' => '; rel="next"' ) + ), + $this->response( array( array( 'id' => 3 ) ) ), + ), + $calls + ); + + $rows = $client->fetch( + 'projects', + array(), + array( + 'company_id' => 1, + 'all' => true, + ) + ); + + $this->assertCount( 3, $rows ); + $this->assertCount( 2, $calls ); + $this->assertSame( array( 1, 2, 3 ), array_column( $rows, 'id' ) ); + } + + /** + * A Link header pointing off-host must never be followed. + * + * @return void + */ + public function test_ignores_link_header_on_a_foreign_host(): void { + $calls = array(); + $client = $this->client_returning( + array( + $this->response( + array( array( 'id' => 1 ) ), + 200, + array( 'Link' => '; rel="next"' ) + ), + ), + $calls + ); + + $rows = $client->fetch( + 'projects', + array(), + array( + 'company_id' => 1, + 'all' => true, + ) + ); + + $this->assertCount( 1, $rows ); + $this->assertCount( 1, $calls ); + } + + /** + * A 429 must be retried after backoff rather than surfaced immediately. + * + * @return void + */ + public function test_retries_after_a_rate_limit_response(): void { + $calls = array(); + $client = $this->client_returning( + array( + $this->response( array( 'message' => 'slow down' ), 429, array( 'Retry-After' => '1' ) ), + $this->response( array( array( 'id' => 7 ) ) ), + ), + $calls + ); + + $rows = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertCount( 2, $calls ); + $this->assertSame( 7, $rows[0]['id'] ); + } + + /** + * Rate-limit headers must be recorded for the Status screen. + * + * @return void + */ + public function test_records_rate_limit_headers(): void { + $client = $this->client_returning( + array( + $this->response( + array(), + 200, + array( + 'X-Rate-Limit-Limit' => '3600', + 'X-Rate-Limit-Remaining' => '3211', + 'X-Rate-Limit-Reset' => '1893456000', + ) + ), + ) + ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $limit = Client::rate_limit(); + + $this->assertSame( 3600, $limit['limit'] ); + $this->assertSame( 3211, $limit['remaining'] ); + } + + /** + * A cached response must be served without a second HTTP call. + * + * @return void + */ + public function test_serves_from_cache_on_the_second_call(): void { + $calls = array(); + $client = $this->client_returning( array( $this->response( array( array( 'id' => 5 ) ) ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + $second = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertCount( 1, $calls ); + $this->assertSame( 5, $second[0]['id'] ); + $this->assertTrue( $client->meta()['cached'] ); + } + + /** + * A Procore outage must serve the last good payload, not an error. + * + * @return void + */ + public function test_falls_back_to_stale_cache_on_failure(): void { + $client = $this->client_returning( array( $this->response( array( array( 'id' => 9 ) ) ) ) ); + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + // Expire the fresh entry but leave the stale copy in place. + foreach ( array_keys( $GLOBALS['procore_connect_test_transients'] ) as $key ) { + if ( 0 === strpos( (string) $key, 'procore_connect_c_' ) ) { + delete_transient( (string) $key ); + } + } + + $client->set_transport( + static function () { + return new \WP_Error( 'down', 'Procore is unreachable.' ); + } + ); + + $result = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertFalse( is_wp_error( $result ) ); + $this->assertSame( 9, $result[0]['id'] ); + $this->assertTrue( $client->meta()['stale'] ); + } + + /** + * Without a stale copy, a hard failure must surface as an error. + * + * @return void + */ + public function test_returns_an_error_when_no_fallback_exists(): void { + $client = $this->client_returning( array( $this->response( array( 'message' => 'Forbidden' ), 403 ) ) ); + + $result = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'Forbidden', $result->get_error_message() ); + } + + /** + * A company-scoped endpoint must refuse to run without a company. + * + * @return void + */ + public function test_requires_a_company_for_scoped_endpoints(): void { + $client = $this->client_returning( array() ); + + $result = $client->fetch( 'projects' ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_missing_company', $result->get_error_code() ); + } + + /** + * A project-scoped endpoint must refuse to run without a project. + * + * @return void + */ + public function test_requires_a_project_for_project_endpoints(): void { + $client = $this->client_returning( array() ); + + $result = $client->fetch( 'rfis', array(), array( 'company_id' => 1 ) ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_missing_project', $result->get_error_code() ); + } + + /** + * Repeated failures must open the circuit and stop further requests. + * + * @return void + */ + public function test_circuit_breaker_opens_after_repeated_failures(): void { + $client = new Client(); + $client->set_auth( new FakeAuth() ); + $client->set_transport( + static function () { + return new \WP_Error( 'down', 'Procore is unreachable.' ); + } + ); + Client::set_instance( $client ); + + for ( $i = 0; $i < 5; $i++ ) { + $client->fetch( 'projects', array( 'nonce' => $i ), array( 'company_id' => 1 ) ); + } + + $this->assertTrue( $client->circuit_is_open() ); + } + + /** + * A shortcode must not be able to cache below the configured floor. + * + * @return void + */ + public function test_cache_floor_is_enforced(): void { + Settings::set( 'cache_floor', 600 ); + + $this->assertSame( 600, Cache::clamp_ttl( 5 ) ); + $this->assertSame( 900, Cache::clamp_ttl( 900 ) ); + } +} diff --git a/tests/unit/EndpointsTest.php b/tests/unit/EndpointsTest.php new file mode 100644 index 0000000..abd18aa --- /dev/null +++ b/tests/unit/EndpointsTest.php @@ -0,0 +1,92 @@ +assertFalse( Endpoints::exists( 'company_admin_users' ) ); + + $path = Endpoints::path( 'company_admin_users' ); + + $this->assertTrue( is_wp_error( $path ) ); + $this->assertSame( 'procore_connect_unknown_endpoint', $path->get_error_code() ); + } + + /** + * Identity endpoints must stay out of reach of shortcodes and the proxy. + * + * @return void + */ + public function test_identity_endpoints_are_not_public(): void { + $public = Endpoints::public_endpoints(); + + $this->assertArrayNotHasKey( 'me', $public ); + $this->assertArrayNotHasKey( 'companies', $public ); + $this->assertArrayHasKey( 'projects', $public ); + } + + /** + * Context identifiers must be interpolated into the path. + * + * @return void + */ + public function test_path_interpolates_identifiers(): void { + $this->assertSame( '/rest/v1.0/projects/88/rfis', Endpoints::path( 'rfis', 1, 88 ) ); + $this->assertSame( '/rest/v1.0/companies/4/offices', Endpoints::path( 'offices', 4 ) ); + } + + /** + * A path placeholder must never be left unfilled. + * + * @return void + */ + public function test_path_refuses_missing_context(): void { + $this->assertTrue( is_wp_error( Endpoints::path( 'rfis', 1, 0 ) ) ); + $this->assertTrue( is_wp_error( Endpoints::path( 'offices', 0 ) ) ); + } + + /** + * Endpoints that scope by query string must receive those parameters. + * + * @return void + */ + public function test_context_query_is_added_where_required(): void { + $this->assertSame( array( 'company_id' => 7 ), Endpoints::context_query( 'projects', 7 ) ); + $this->assertSame( array( 'project_id' => 12 ), Endpoints::context_query( 'punch_items', 7, 12 ) ); + $this->assertSame( array(), Endpoints::context_query( 'rfis', 7, 12 ) ); + } + + /** + * Every registered endpoint must declare the fields it can render. + * + * @return void + */ + public function test_registry_entries_are_complete(): void { + foreach ( Endpoints::all() as $slug => $definition ) { + $this->assertNotSame( '', $definition['path'], $slug . ' has no path' ); + $this->assertNotSame( '', $definition['label'], $slug . ' has no label' ); + $this->assertGreaterThan( 0, $definition['ttl'], $slug . ' has no cache lifetime' ); + $this->assertIsArray( $definition['fields'], $slug . ' has no fields' ); + $this->assertStringStartsWith( '/rest/', $definition['path'], $slug . ' is not a REST path' ); + } + } +} diff --git a/tests/unit/FakeAuth.php b/tests/unit/FakeAuth.php new file mode 100644 index 0000000..0dc3b98 --- /dev/null +++ b/tests/unit/FakeAuth.php @@ -0,0 +1,71 @@ +token = $token; + } + + /** + * Return the canned token. + * + * @return string|\WP_Error Token or error. + */ + public function access_token() { + return $this->token; + } + + /** + * Always configured. + * + * @return bool True. + */ + public function is_configured(): bool { + return true; + } + + /** + * Grant type identifier. + * + * @return string Grant type. + */ + public function grant_type(): string { + return 'client_credentials'; + } + + /** + * Human readable name. + * + * @return string Label. + */ + public function label(): string { + return 'Fake'; + } +} diff --git a/tests/unit/SettingsTest.php b/tests/unit/SettingsTest.php new file mode 100644 index 0000000..6365731 --- /dev/null +++ b/tests/unit/SettingsTest.php @@ -0,0 +1,163 @@ + 'implicit', + 'environment' => 'staging', + 'rest_access' => 'everyone', + ) + ); + + $this->assertSame( 'client_credentials', $clean['auth_mode'] ); + $this->assertSame( 'production', $clean['environment'] ); + $this->assertSame( 'logged_in', $clean['rest_access'] ); + } + + /** + * Numeric settings must be clamped to their documented ranges. + * + * @return void + */ + public function test_clamps_numeric_settings(): void { + $clean = Settings::sanitize( + array( + 'per_page' => 99999, + 'cache_floor' => 1, + 'request_timeout' => 600, + ) + ); + + $this->assertSame( 2000, $clean['per_page'] ); + $this->assertSame( 30, $clean['cache_floor'] ); + $this->assertSame( 60, $clean['request_timeout'] ); + } + + /** + * The secret must be encrypted before it reaches storage. + * + * @return void + */ + public function test_encrypts_the_client_secret(): void { + $clean = Settings::sanitize( array( 'client_secret' => 'super-secret-value' ) ); + + $this->assertNotSame( 'super-secret-value', $clean['client_secret'] ); + $this->assertSame( 'super-secret-value', Encryption::decrypt( $clean['client_secret'] ) ); + } + + /** + * An empty secret field must leave the stored secret untouched, because the + * form renders a mask rather than the real value. + * + * @return void + */ + public function test_blank_secret_preserves_the_stored_value(): void { + update_option( Settings::OPTION, array( 'client_secret' => Encryption::encrypt( 'existing' ) ) ); + Settings::flush(); + + $clean = Settings::sanitize( array( 'client_secret' => '' ) ); + + $this->assertSame( 'existing', Encryption::decrypt( $clean['client_secret'] ) ); + } + + /** + * An explicit clear request must remove the stored secret. + * + * @return void + */ + public function test_secret_can_be_cleared_explicitly(): void { + update_option( Settings::OPTION, array( 'client_secret' => Encryption::encrypt( 'existing' ) ) ); + Settings::flush(); + + $clean = Settings::sanitize( array( 'clear_client_secret' => '1' ) ); + + $this->assertSame( '', $clean['client_secret'] ); + } + + /** + * Custom hosts must be restricted to HTTPS URLs. + * + * @return void + */ + public function test_custom_hosts_must_be_https(): void { + $clean = Settings::sanitize( + array( + 'custom_api_url' => 'http://api.internal.test', + 'custom_login_url' => 'javascript:alert(1)', + ) + ); + + $this->assertSame( '', $clean['custom_api_url'] ); + $this->assertSame( '', $clean['custom_login_url'] ); + } + + /** + * Custom CSS must never be able to inject markup. + * + * @return void + */ + public function test_custom_css_is_stripped_of_tags(): void { + $clean = Settings::sanitize( array( 'custom_css' => '.a{color:red}' ) ); + + $this->assertStringNotContainsString( '' ); + + $rendered = Format::cell( + $record, + array( + 'key' => 'name', + 'format' => 'text', + ) + ); + + $this->assertStringNotContainsString( '