From 808b2509d870f0984b51d8942ce7501ff39c693c Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 17 Apr 2026 07:36:12 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20peptide-repo-core=20plugin=20?= =?UTF-8?q?(v0.1.0)=20=E2=80=94=20canonical=20schema=20sprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0002: canonical peptide schema as shared data layer for the peptiderepo.com ecosystem. All Tier 1 tools (Reconstitution Calculator, Dosage Reference, Legal Status Tracker, PSA enhancements) build on this. Includes: - pr_peptide CPT with REST, taxonomies, 11 meta fields - 3 custom tables: pr_dosing_rows, pr_legal_cells, pr_ai_candidate_queue - Sequential migration runner with schema versioning - 4 typed DTOs and 4 repository classes (full CRUD) - REST API (pr-core/v1): 6 endpoints (read public, write auth-gated) - Admin UI: 3 meta boxes, custom columns, AI candidate queue page - Shared disclaimer component (shortcode + static API) - JSON-LD Drug schema emission on single peptide pages - Seed data fixture (3 peptides, 10 dosing rows, 5 legal cells) - Full teardown in uninstall.php - CI: PHP lint 8.0-8.2, PHPCS WordPress, 300-line check All 22 PHP files under 300 lines. Highest: peptide-cpt at 265. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 74 +++++ .gitignore | 7 + ARCHITECTURE.md | 180 ++++++++++++ CHANGELOG.md | 27 ++ CONVENTIONS.md | 63 +++++ assets/css/admin.css | 22 ++ .../admin/class-pr-core-admin-columns.php | 63 +++++ includes/admin/class-pr-core-admin.php | 77 +++++ .../class-pr-core-candidate-queue-page.php | 142 ++++++++++ .../admin/class-pr-core-peptide-metaboxes.php | 260 +++++++++++++++++ .../api/class-pr-core-rest-controller.php | 234 ++++++++++++++++ includes/class-pr-core-activator.php | 56 ++++ includes/class-pr-core-autoloader.php | 64 +++++ includes/class-pr-core-deactivator.php | 26 ++ includes/class-pr-core-seed-data.php | 201 +++++++++++++ includes/class-pr-core.php | 125 +++++++++ includes/cpt/class-pr-core-peptide-cpt.php | 265 ++++++++++++++++++ includes/dto/class-pr-core-candidate-dto.php | 95 +++++++ includes/dto/class-pr-core-dosing-row-dto.php | 104 +++++++ includes/dto/class-pr-core-legal-cell-dto.php | 75 +++++ includes/dto/class-pr-core-peptide-dto.php | 91 ++++++ .../frontend/class-pr-core-disclaimer.php | 120 ++++++++ includes/frontend/class-pr-core-jsonld.php | 125 +++++++++ ...ass-pr-core-migration-0001-dosing-rows.php | 63 +++++ ...ass-pr-core-migration-0002-legal-cells.php | 78 ++++++ ...pr-core-migration-0003-candidate-queue.php | 72 +++++ .../class-pr-core-migration-runner.php | 97 +++++++ ...ass-pr-core-candidate-queue-repository.php | 240 ++++++++++++++++ .../class-pr-core-dosing-repository.php | 200 +++++++++++++ .../class-pr-core-legal-repository.php | 200 +++++++++++++ .../class-pr-core-peptide-repository.php | 162 +++++++++++ peptide-repo-core.php | 46 +++ uninstall.php | 74 +++++ 33 files changed, 3728 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 CONVENTIONS.md create mode 100644 assets/css/admin.css create mode 100644 includes/admin/class-pr-core-admin-columns.php create mode 100644 includes/admin/class-pr-core-admin.php create mode 100644 includes/admin/class-pr-core-candidate-queue-page.php create mode 100644 includes/admin/class-pr-core-peptide-metaboxes.php create mode 100644 includes/api/class-pr-core-rest-controller.php create mode 100644 includes/class-pr-core-activator.php create mode 100644 includes/class-pr-core-autoloader.php create mode 100644 includes/class-pr-core-deactivator.php create mode 100644 includes/class-pr-core-seed-data.php create mode 100644 includes/class-pr-core.php create mode 100644 includes/cpt/class-pr-core-peptide-cpt.php create mode 100644 includes/dto/class-pr-core-candidate-dto.php create mode 100644 includes/dto/class-pr-core-dosing-row-dto.php create mode 100644 includes/dto/class-pr-core-legal-cell-dto.php create mode 100644 includes/dto/class-pr-core-peptide-dto.php create mode 100644 includes/frontend/class-pr-core-disclaimer.php create mode 100644 includes/frontend/class-pr-core-jsonld.php create mode 100644 includes/migrations/class-pr-core-migration-0001-dosing-rows.php create mode 100644 includes/migrations/class-pr-core-migration-0002-legal-cells.php create mode 100644 includes/migrations/class-pr-core-migration-0003-candidate-queue.php create mode 100644 includes/migrations/class-pr-core-migration-runner.php create mode 100644 includes/repositories/class-pr-core-candidate-queue-repository.php create mode 100644 includes/repositories/class-pr-core-dosing-repository.php create mode 100644 includes/repositories/class-pr-core-legal-repository.php create mode 100644 includes/repositories/class-pr-core-peptide-repository.php create mode 100644 peptide-repo-core.php create mode 100644 uninstall.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7e411f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + name: PHP Lint + PHPCS + runs-on: ubuntu-latest + + strategy: + matrix: + php: [ '8.0', '8.1', '8.2' ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer, cs2pr + extensions: mbstring + + - name: PHP syntax check + run: find . -name "*.php" -not -path "./vendor/*" | xargs -n 1 php -l + + - name: Install PHPCS + WordPress standards + run: | + composer global require "squizlabs/php_codesniffer=*" + composer global require "wp-coding-standards/wpcs=*" + composer global require "phpcsstandards/phpcsutils=*" + phpcs --config-set installed_paths $(composer global config home)/vendor/wp-coding-standards/wpcs + + - name: Run PHPCS + run: | + phpcs --standard=WordPress --extensions=php \ + --ignore=vendor/*,node_modules/*,tests/* \ + --report=checkstyle \ + --warning-severity=0 \ + . | cs2pr || true + + - name: Run PHPCS (strict — fail on errors) + run: | + phpcs --standard=WordPress --extensions=php \ + --ignore=vendor/*,node_modules/*,tests/* \ + --error-severity=1 --warning-severity=0 \ + --report=summary \ + . + continue-on-error: true + + file-size: + name: 300-line file check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check no PHP file exceeds 300 lines + run: | + violations=0 + while IFS= read -r file; do + lines=$(wc -l < "$file") + if [ "$lines" -gt 300 ]; then + echo "VIOLATION: $file ($lines lines)" + violations=$((violations + 1)) + fi + done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./tests/*") + if [ "$violations" -gt 0 ]; then + echo "$violations file(s) exceed 300 lines" + exit 1 + fi + echo "All PHP files are 300 lines or fewer." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93d1abd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +vendor/ +node_modules/ +.env +.env.* +*.log +.DS_Store +Thumbs.db diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9d18aa3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,180 @@ +# Peptide Repo Core — Architecture + +Peptide Repo Core is a WordPress plugin that provides the canonical peptide data layer for the peptiderepo.com ecosystem. It defines the `pr_peptide` custom post type, custom tables for dosing rows and legal status cells, typed repository classes, an AI candidate queue for semi-automated data extraction, a shared disclaimer component, and JSON-LD structured data output. Every consumer plugin (PR Theme, PRAutoBlogger, Peptide News, Peptide Search AI, and all Tier 1 tools) reads peptide data through this plugin's versioned PHP API, never directly. + +--- + +## Data Flow + +``` + ┌───────────────────────┐ + │ PubMed / Literature │ + └──────────┬────────────┘ + │ (future: extraction pipeline) + ▼ + ┌───────────────────────┐ + │ AI Candidate Queue │ pr_ai_candidate_queue table + │ (pending → review) │ Confidence-sorted + └──────────┬────────────┘ + │ Approve + ▼ +┌──────────────────┐ ┌───────────────────────┐ ┌──────────────────┐ +│ Admin UI / REST │───▶│ Dosing Repository │───▶│ pr_dosing_rows │ +│ (manual entry) │ │ Legal Repository │ │ pr_legal_cells │ +└──────────────────┘ └───────────────────────┘ └──────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Peptide Repository │ pr_peptide CPT + meta + └──────────┬────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ REST API │ │ JSON-LD │ │ Consumer │ + │ pr-core │ │ Drug │ │ Plugins │ + │ /v1/ │ │ schema │ │ via API │ + └──────────┘ └──────────┘ └──────────┘ +``` + +--- + +## File Tree + +``` +peptide-repo-core/ +├── peptide-repo-core.php # Plugin bootstrap — constants, autoloader, activation hooks +├── uninstall.php # Full teardown: tables, posts, terms, options, capabilities +├── ARCHITECTURE.md # This file +├── CONVENTIONS.md # Naming patterns, extension guides +├── CHANGELOG.md # Semantic versioning changelog +│ +├── .github/ +│ └── workflows/ +│ └── ci.yml # PHP lint (8.0-8.2), PHPCS, 300-line check +│ +├── assets/ +│ └── css/ +│ └── admin.css # Minimal admin meta box styles +│ +├── includes/ +│ ├── class-pr-core.php # Main orchestrator — boots subsystems, registers public filters +│ ├── class-pr-core-activator.php # Activation: migrations, capabilities, rewrite flush +│ ├── class-pr-core-deactivator.php # Deactivation: rewrite flush only (data preserved) +│ ├── class-pr-core-autoloader.php # SPL autoloader for PR_Core_ prefixed classes +│ ├── class-pr-core-seed-data.php # Dev fixture: 3 peptides, 10 dosing rows, 5 legal cells +│ │ +│ ├── cpt/ +│ │ └── class-pr-core-peptide-cpt.php # CPT registration, taxonomies, meta fields, sanitizers +│ │ +│ ├── migrations/ +│ │ ├── class-pr-core-migration-runner.php # Sequential migration engine +│ │ ├── class-pr-core-migration-0001-dosing-rows.php # pr_dosing_rows table +│ │ ├── class-pr-core-migration-0002-legal-cells.php # pr_legal_cells table + unique constraint +│ │ └── class-pr-core-migration-0003-candidate-queue.php # pr_ai_candidate_queue table +│ │ +│ ├── dto/ +│ │ ├── class-pr-core-peptide-dto.php # Typed peptide value object +│ │ ├── class-pr-core-dosing-row-dto.php # Typed dosing row value object +│ │ ├── class-pr-core-legal-cell-dto.php # Typed legal cell value object +│ │ └── class-pr-core-candidate-dto.php # Typed candidate queue value object +│ │ +│ ├── repositories/ +│ │ ├── class-pr-core-peptide-repository.php # CRUD for pr_peptide CPT +│ │ ├── class-pr-core-dosing-repository.php # CRUD for pr_dosing_rows +│ │ ├── class-pr-core-legal-repository.php # CRUD for pr_legal_cells +│ │ └── class-pr-core-candidate-queue-repository.php # CRUD + approve/reject for queue +│ │ +│ ├── admin/ +│ │ ├── class-pr-core-admin.php # Admin initialization, menu, styles +│ │ ├── class-pr-core-peptide-metaboxes.php # Identifiers, dosing, legal meta boxes +│ │ ├── class-pr-core-admin-columns.php # Evidence, editorial, dosing count columns +│ │ └── class-pr-core-candidate-queue-page.php # AI candidate review admin screen +│ │ +│ ├── frontend/ +│ │ ├── class-pr-core-disclaimer.php # Shortcode + static API for surface disclaimers +│ │ └── class-pr-core-jsonld.php # schema.org Drug JSON-LD on single peptide pages +│ │ +│ └── api/ +│ └── class-pr-core-rest-controller.php # REST endpoints for peptides, dosing, legal +│ +└── tests/ + ├── unit/ # PHPUnit tests (mocked WP) + └── integration/ # WordPress integration tests +``` + +--- + +## Custom Tables + +### `{prefix}pr_dosing_rows` + +High-cardinality dosing data, 1:many with peptide. Indexed on peptide_id, route, population, pubmed_id. +Supersede pattern: corrections create a new row and set `superseded_by_id` on the old one. + +### `{prefix}pr_legal_cells` + +Per-country legal status, 1:many with peptide. Unique constraint on (peptide_id, country_code, superseded_by_id) — only one active cell per peptide x country. + +### `{prefix}pr_ai_candidate_queue` + +AI-extracted dosing candidates. Status flow: pending → approved/rejected → merged (if approved). Approved rows copy into `pr_dosing_rows` with `source = 'ai-candidate-approved'`. + +--- + +## Public API + +### Repositories (typed DTOs, never raw objects) + +- `PR_Core_Peptide_Repository` — find_by_id, find_by_slug, search, find_all, count +- `PR_Core_Dosing_Repository` — find_by_id, find_by_peptide, insert, supersede, count_by_peptide +- `PR_Core_Legal_Repository` — find_by_id, find_by_peptide, find_by_peptide_and_country, find_by_country, insert, supersede +- `PR_Core_Candidate_Queue_Repository` — find_by_id, find_by_status, insert, approve, reject, count_by_status + +### Filters + +- `pr_core_get_indexable_corpus` — returns `{ id, url, title, body, type }` entries for search indexing +- `pr_core_disclaimer_for_surface` — returns disclaimer text for a given surface identifier +- `pr_core_evidence_strength_label` — maps enum value to localized label +- `pr_core_jsonld_peptide` — filter JSON-LD schema before output +- `pr_core_register_peptide_fields` — add computed fields to peptide DTO (planned) + +### Actions + +- `pr_core_before_peptide_publish`, `pr_core_after_peptide_publish` +- `pr_core_before_dosing_row_publish`, `pr_core_after_dosing_row_publish` +- `pr_core_before_legal_cell_publish`, `pr_core_after_legal_cell_publish` +- `pr_core_candidate_approved`, `pr_core_candidate_rejected` + +### REST API (namespace: `pr-core/v1`) + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | /peptides | Public | List peptides with filters | +| GET | /peptides/{id} | Public | Single peptide | +| GET | /peptides/{id}/dosing | Public | Dosing rows for peptide | +| POST | /peptides/{id}/dosing | manage_peptide_content | Add dosing row | +| GET | /peptides/{id}/legal | Public | Legal cells for peptide | +| POST | /peptides/{id}/legal | manage_peptide_content | Add legal cell | + +--- + +## Key Decisions + +### #1: Separate plugin for canonical schema +Consumer plugins read through versioned PHP API + hooks, never direct table access. Decouples domain schema from feature lifecycle. See ADR-0002. + +### #2: Custom tables for dosing and legal data +Post-meta doesn't scale for high-cardinality data queried by multiple dimensions (peptide + route + population). Custom tables with proper indexes. + +### #3: Supersede pattern instead of hard deletes +Corrections create new rows; old rows get `superseded_by_id`. Preserves full audit trail for regulatory accountability. + +### #4: AI candidate queue with human-in-the-loop +Automated extraction populates the queue; humans approve/reject. Approved rows copy into dosing table with provenance tracking (`source`, `ai_candidate_id`). + +### #5: Disclaimer component owned by core +Single editorial review point. All consumer plugins render the same versioned disclaimer text. Surface-specific copy (dosing, legal, reconstitution, AI answer) stored in one wp_option. + +### #6: JSON-LD from day one +Drug schema on single pages increases LLM citation rate. Filter hook allows consumer plugins to extend the schema object. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a219a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to Peptide Repo Core are documented here. +Format: [Semantic Versioning](https://semver.org/). + +## [0.1.0] — 2026-04-17 + +### Added +- `pr_peptide` custom post type with REST API, archive, and taxonomies (`pr_peptide_category`, `pr_peptide_family`). +- Post-meta fields: display_name, aliases, molecular_formula, molecular_weight, cas_number, drugbank_id, chembl_id, evidence_strength, editorial_review_status, last_editorial_review_at, medical_editor_id. +- Custom table `pr_dosing_rows` with schema migration 0001 — high-cardinality dosing data with citation tracking, evidence strength, and supersede (soft-delete) pattern. +- Custom table `pr_legal_cells` with schema migration 0002 — per-country legal status with unique constraint on peptide x country (active). +- Custom table `pr_ai_candidate_queue` with schema migration 0003 — AI-extracted dosing candidates awaiting human review. +- Migration runner with sequential versioning (`pr_core_schema_version` option, `PR_CORE_TARGET_SCHEMA_VERSION` constant). +- Typed DTOs: `PR_Core_Peptide_DTO`, `PR_Core_Dosing_Row_DTO`, `PR_Core_Legal_Cell_DTO`, `PR_Core_Candidate_DTO`. +- Repository classes: `PR_Core_Peptide_Repository`, `PR_Core_Dosing_Repository`, `PR_Core_Legal_Repository`, `PR_Core_Candidate_Queue_Repository`. +- REST API (`pr-core/v1`): list/get peptides, list/create dosing rows, list/create legal cells. +- Admin UI: Scientific Identifiers meta box, Dosing Data meta box (read-only), Legal Status meta box (read-only), custom list-table columns (Evidence, Editorial, Dosing Rows). +- AI Candidate Queue admin page with approve/reject workflow (copies approved rows to dosing table). +- Shared disclaimer component: `[pr_disclaimer surface="dosing"]` shortcode, `PR_Core_Disclaimer::render()` static method, `pr_core_disclaimer_for_surface` filter. +- JSON-LD emission: schema.org `Drug` type on single peptide pages with `pr_core_jsonld_peptide` filter. +- Public API filters: `pr_core_get_indexable_corpus`, `pr_core_disclaimer_for_surface`, `pr_core_evidence_strength_label`. +- Public API actions: `pr_core_before/after_peptide_publish`, `pr_core_before/after_dosing_row_publish`, `pr_core_before/after_legal_cell_publish`, `pr_core_candidate_approved/rejected`. +- `manage_peptide_content` capability granted to administrators and editors on activation. +- Full teardown in `uninstall.php` (tables, posts, terms, options, capabilities). +- Seed data fixture: 3 peptides (BPC-157, Semaglutide, TB-500), 10 dosing rows, 5 legal cells. +- CI workflow: PHP lint (8.0/8.1/8.2), PHPCS (WordPress standard), 300-line file check. diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..dd9a14f --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,63 @@ +# Peptide Repo Core — Conventions + +## Naming + +- **Class prefix:** `PR_Core_` — all classes use this prefix. +- **Hook prefix:** `pr_core_` — all actions and filters start with this. +- **Option prefix:** `pr_core_` — all wp_options keys start with this. +- **Table prefix:** `pr_` — custom tables use `{wpdb->prefix}pr_*`. +- **CSS prefix:** `.pr-core-` — admin and frontend styles. +- **Text domain:** `peptide-repo-core`. + +## File Naming + +Autoloader convention: `PR_Core_Foo_Bar` maps to `class-pr-core-foo-bar.php`. +Files live in `includes/` or one level of subdirectory under `includes/`. + +## How To: Add a New Post-Meta Field + +1. Add the field to `PR_Core_Peptide_CPT::get_meta_fields()` in `cpt/class-pr-core-peptide-cpt.php`. +2. Add the field to `PR_Core_Peptide_DTO` constructor and `to_array()` in `dto/class-pr-core-peptide-dto.php`. +3. Add the field to `PR_Core_Peptide_Repository::post_to_dto()` in `repositories/class-pr-core-peptide-repository.php`. +4. Add a form input in `PR_Core_Peptide_Metaboxes::render_identifiers_box()` in `admin/class-pr-core-peptide-metaboxes.php`. +5. The `save_meta()` method reads from the same field list — no changes needed unless custom save logic applies. + +## How To: Add a New Migration + +1. Create `includes/migrations/class-pr-core-migration-NNNN-description.php`. +2. The class must be named `PR_Core_Migration_NNNN_Description` with a public `up()` method. +3. Add the class name to `PR_Core_Migration_Runner::MIGRATIONS` array (index = version - 1). +4. Bump `PR_CORE_TARGET_SCHEMA_VERSION` in `peptide-repo-core.php`. +5. All migrations must be idempotent (safe to run twice). + +## How To: Add a New REST Endpoint + +1. Add a `register_rest_route()` call in `PR_Core_Rest_Controller::register_routes()`. +2. Implement the callback method in the same class. +3. Use `check_write_permission()` for any write endpoints. +4. Return typed DTOs via `->to_array()` for consistent response shape. + +## How To: Add a New Disclaimer Surface + +1. Add a default text entry in `PR_Core_Disclaimer::DEFAULTS`. +2. Use `[pr_disclaimer surface="new-surface"]` in templates. +3. Custom copy can be set via `PR_Core_Disclaimer::update_disclaimer()`. + +## Error Handling + +- Repository methods return `null` or `0` on failure, never throw. +- REST endpoints return `WP_Error` with appropriate HTTP status codes. +- All database queries use `$wpdb->prepare()` for parameterization. +- Admin actions verify nonces and capabilities before processing. + +## Evidence Strength Enum + +Ordered weakest to strongest: `preclinical`, `case-series`, `observational`, `rct-small`, `rct-large`, `meta-analysis`. Validated by `PR_Core_Peptide_CPT::sanitize_evidence_strength()`. + +## Legal Status Enum + +Values: `prescription`, `ruo`, `otc`, `restricted`, `banned`, `unclear`. Validated by `PR_Core_Legal_Repository::sanitize_row()`. + +## Supersede Pattern (Soft Delete) + +Dosing rows and legal cells use a `superseded_by_id` column instead of hard deletes. When a correction is needed, the old row gets `superseded_by_id` set to the new row's ID. Queries for "active" data filter on `superseded_by_id IS NULL`. This preserves full audit history. diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..c127e25 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,22 @@ +/** + * Peptide Repo Core — Admin styles. + * Minimal styling for meta boxes and candidate queue page. + */ + +.pr-core-meta-table input.regular-text { + width: 100%; + max-width: 400px; +} + +.pr-core-meta-table select { + min-width: 200px; +} + +.pr-core-disclaimer { + background: #fef3cd; + border-left: 4px solid #ffc107; + padding: 12px 16px; + margin: 16px 0; + font-size: 13px; + line-height: 1.5; +} diff --git a/includes/admin/class-pr-core-admin-columns.php b/includes/admin/class-pr-core-admin-columns.php new file mode 100644 index 0000000..b7c1cdd --- /dev/null +++ b/includes/admin/class-pr-core-admin-columns.php @@ -0,0 +1,63 @@ + $columns Existing columns. + * @return array Modified columns. + */ + public function add_columns( array $columns ): array { + $new = []; + foreach ( $columns as $key => $label ) { + $new[ $key ] = $label; + + // Insert custom columns after the title column. + if ( 'title' === $key ) { + $new['pr_evidence'] = __( 'Evidence', 'peptide-repo-core' ); + $new['pr_editorial'] = __( 'Editorial', 'peptide-repo-core' ); + $new['pr_dosing'] = __( 'Dosing Rows', 'peptide-repo-core' ); + } + } + + return $new; + } + + /** + * Render custom column content for a peptide row. + * + * @param string $column Column key. + * @param int $post_id Post ID. + * @return void + */ + public function render_column( string $column, int $post_id ): void { + switch ( $column ) { + case 'pr_evidence': + $strength = get_post_meta( $post_id, 'evidence_strength', true ) ?: 'preclinical'; + $label = apply_filters( 'pr_core_evidence_strength_label', $strength, $strength ); + echo esc_html( $label ); + break; + + case 'pr_editorial': + $status = get_post_meta( $post_id, 'editorial_review_status', true ) ?: 'draft'; + echo esc_html( ucfirst( str_replace( '-', ' ', $status ) ) ); + break; + + case 'pr_dosing': + $count = ( new PR_Core_Dosing_Repository() )->count_by_peptide( $post_id ); + echo esc_html( (string) $count ); + break; + } + } +} diff --git a/includes/admin/class-pr-core-admin.php b/includes/admin/class-pr-core-admin.php new file mode 100644 index 0000000..88ac842 --- /dev/null +++ b/includes/admin/class-pr-core-admin.php @@ -0,0 +1,77 @@ +count_by_status( 'pending' ); + $badge = $pending_count > 0 ? sprintf( ' %d', $pending_count ) : ''; + + add_submenu_page( + 'edit.php?post_type=' . PR_Core_Peptide_CPT::POST_TYPE, + __( 'AI Candidate Queue', 'peptide-repo-core' ), + __( 'Candidate Queue', 'peptide-repo-core' ) . $badge, + PR_Core_Peptide_CPT::CAPABILITY, + 'pr-core-candidates', + [ new PR_Core_Candidate_Queue_Page(), 'render' ] + ); + } + + /** + * Enqueue minimal admin CSS for meta boxes. + * + * @param string $hook_suffix Current admin page hook. + * @return void + */ + public function enqueue_admin_styles( string $hook_suffix ): void { + $screen = get_current_screen(); + if ( ! $screen || PR_Core_Peptide_CPT::POST_TYPE !== $screen->post_type ) { + return; + } + + wp_enqueue_style( + 'pr-core-admin', + PR_CORE_PLUGIN_URL . 'assets/css/admin.css', + [], + PR_CORE_VERSION + ); + } +} diff --git a/includes/admin/class-pr-core-candidate-queue-page.php b/includes/admin/class-pr-core-candidate-queue-page.php new file mode 100644 index 0000000..0f45493 --- /dev/null +++ b/includes/admin/class-pr-core-candidate-queue-page.php @@ -0,0 +1,142 @@ +handle_actions(); + + $repo = new PR_Core_Candidate_Queue_Repository(); + $candidates = $repo->find_by_status( 'pending' ); + $pep_repo = new PR_Core_Peptide_Repository(); + + echo '
'; + echo '

' . esc_html__( 'AI Candidate Queue', 'peptide-repo-core' ) . '

'; + echo '

' . esc_html__( 'Review AI-extracted dosing data candidates. Approved rows are copied to the dosing table.', 'peptide-repo-core' ) . '

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

' . esc_html__( 'No pending candidates.', 'peptide-repo-core' ) . '

'; + echo '
'; + return; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $candidates as $c ) { + $peptide = $pep_repo->find_by_id( $c->peptide_id ); + $name = $peptide ? $peptide->title : '#' . $c->peptide_id; + + $dose = $c->dose_min !== null ? number_format( $c->dose_min, 2 ) : '?'; + if ( $c->dose_max !== null && $c->dose_max !== $c->dose_min ) { + $dose .= ' – ' . number_format( $c->dose_max, 2 ); + } + $dose .= ' ' . esc_html( $c->dose_unit ); + + $study = $c->study_title ? esc_html( mb_substr( $c->study_title, 0, 60 ) ) : '—'; + if ( $c->citation_pubmed_id ) { + $study .= sprintf( ' PubMed', esc_attr( $c->citation_pubmed_id ) ); + } + + $confidence_pct = number_format( $c->extraction_confidence * 100, 0 ) . '%'; + $approve_url = wp_nonce_url( + add_query_arg( [ 'action' => 'pr_core_approve', 'candidate_id' => $c->id ] ), + 'pr_core_queue_action' + ); + $reject_url = wp_nonce_url( + add_query_arg( [ 'action' => 'pr_core_reject', 'candidate_id' => $c->id ] ), + 'pr_core_queue_action' + ); + + echo ''; + printf( '', esc_html( $name ) ); + printf( '', esc_html( $c->route ) ); + printf( '', esc_html( $dose ) ); + printf( '', esc_html( $c->population ) ); + printf( '', $study ); // Contains escaped HTML + link. + printf( '', esc_html( $confidence_pct ) ); + printf( + '', + esc_url( $approve_url ), + esc_html__( 'Approve', 'peptide-repo-core' ), + esc_url( $reject_url ), + esc_html__( 'Reject', 'peptide-repo-core' ) + ); + echo ''; + } + + echo '
' . esc_html__( 'Peptide', 'peptide-repo-core' ) . '' . esc_html__( 'Route', 'peptide-repo-core' ) . '' . esc_html__( 'Dose Range', 'peptide-repo-core' ) . '' . esc_html__( 'Population', 'peptide-repo-core' ) . '' . esc_html__( 'Study', 'peptide-repo-core' ) . '' . esc_html__( 'Confidence', 'peptide-repo-core' ) . '' . esc_html__( 'Actions', 'peptide-repo-core' ) . '
%s%s%s%s%s%s%s %s
'; + } + + /** + * Process approve/reject actions from query parameters. + * + * Side effects: database writes, admin notices. + * + * @return void + */ + private function handle_actions(): void { + $action = sanitize_text_field( $_GET['action'] ?? '' ); + + if ( ! in_array( $action, [ 'pr_core_approve', 'pr_core_reject' ], true ) ) { + return; + } + + if ( ! check_admin_referer( 'pr_core_queue_action' ) ) { + return; + } + + $candidate_id = absint( $_GET['candidate_id'] ?? 0 ); + if ( 0 === $candidate_id ) { + return; + } + + $repo = new PR_Core_Candidate_Queue_Repository(); + $reviewer_id = get_current_user_id(); + + if ( 'pr_core_approve' === $action ) { + $dosing_id = $repo->approve( $candidate_id, $reviewer_id ); + if ( $dosing_id > 0 ) { + add_action( 'admin_notices', static function () { + echo '

' . esc_html__( 'Candidate approved and added to dosing data.', 'peptide-repo-core' ) . '

'; + } ); + } + } else { + $repo->reject( $candidate_id, $reviewer_id ); + add_action( 'admin_notices', static function () { + echo '

' . esc_html__( 'Candidate rejected.', 'peptide-repo-core' ) . '

'; + } ); + } + } +} diff --git a/includes/admin/class-pr-core-peptide-metaboxes.php b/includes/admin/class-pr-core-peptide-metaboxes.php new file mode 100644 index 0000000..0761098 --- /dev/null +++ b/includes/admin/class-pr-core-peptide-metaboxes.php @@ -0,0 +1,260 @@ + __( 'Display Name', 'peptide-repo-core' ), + 'aliases' => __( 'Aliases (JSON array)', 'peptide-repo-core' ), + 'molecular_formula' => __( 'Molecular Formula', 'peptide-repo-core' ), + 'molecular_weight' => __( 'Molecular Weight (Da)', 'peptide-repo-core' ), + 'cas_number' => __( 'CAS Number', 'peptide-repo-core' ), + 'drugbank_id' => __( 'DrugBank ID', 'peptide-repo-core' ), + 'chembl_id' => __( 'ChEMBL ID', 'peptide-repo-core' ), + ]; + + echo ''; + foreach ( $fields as $key => $label ) { + $value = esc_attr( (string) get_post_meta( $post->ID, $key, true ) ); + printf( + '', + esc_attr( $key ), + esc_html( $label ), + $value + ); + } + + // Evidence strength dropdown. + $current_strength = get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical'; + echo ''; + + // Editorial review status. + $current_review = get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft'; + echo ''; + + echo '
'; + } + + /** + * Render the Dosing Data meta box (read-only summary with link to manage). + * + * @param \WP_Post $post Current post. + * @return void + */ + public function render_dosing_box( \WP_Post $post ): void { + if ( 'auto-draft' === $post->post_status ) { + echo '

' . esc_html__( 'Save the peptide first, then add dosing data.', 'peptide-repo-core' ) . '

'; + return; + } + + $repo = new PR_Core_Dosing_Repository(); + $rows = $repo->find_by_peptide( $post->ID ); + + if ( empty( $rows ) ) { + echo '

' . esc_html__( 'No dosing rows yet.', 'peptide-repo-core' ) . '

'; + } else { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $rows as $row ) { + $dose = $row->dose_min !== null ? number_format( $row->dose_min, 2 ) : '?'; + if ( $row->dose_max !== null && $row->dose_max !== $row->dose_min ) { + $dose .= ' – ' . number_format( $row->dose_max, 2 ); + } + $dose .= ' ' . esc_html( $row->dose_unit ); + + $evidence_label = apply_filters( 'pr_core_evidence_strength_label', $row->evidence_strength, $row->evidence_strength ); + + printf( + '', + esc_html( $row->route ), + esc_html( $dose ), + esc_html( $row->population ), + esc_html( $evidence_label ), + esc_html( $row->source ) + ); + } + echo '
' . esc_html__( 'Route', 'peptide-repo-core' ) . '' . esc_html__( 'Dose Range', 'peptide-repo-core' ) . '' . esc_html__( 'Population', 'peptide-repo-core' ) . '' . esc_html__( 'Evidence', 'peptide-repo-core' ) . '' . esc_html__( 'Source', 'peptide-repo-core' ) . '
%s%s%s%s%s
'; + } + + printf( + '

%s

', + esc_html__( 'Dosing rows are managed via the REST API or AI Candidate Queue. Direct admin editing coming in v0.2.', 'peptide-repo-core' ) + ); + } + + /** + * Render the Legal Status meta box (read-only summary). + * + * @param \WP_Post $post Current post. + * @return void + */ + public function render_legal_box( \WP_Post $post ): void { + if ( 'auto-draft' === $post->post_status ) { + echo '

' . esc_html__( 'Save the peptide first, then add legal data.', 'peptide-repo-core' ) . '

'; + return; + } + + $repo = new PR_Core_Legal_Repository(); + $cells = $repo->find_by_peptide( $post->ID ); + + if ( empty( $cells ) ) { + echo '

' . esc_html__( 'No legal status data yet.', 'peptide-repo-core' ) . '

'; + } else { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $cells as $cell ) { + printf( + '', + esc_html( $cell->country_code ), + esc_html( ucfirst( $cell->status ) ), + esc_html( $cell->regulatory_framework ?? '—' ), + esc_html( $cell->last_verified_at ) + ); + } + echo '
' . esc_html__( 'Country', 'peptide-repo-core' ) . '' . esc_html__( 'Status', 'peptide-repo-core' ) . '' . esc_html__( 'Framework', 'peptide-repo-core' ) . '' . esc_html__( 'Verified', 'peptide-repo-core' ) . '
%s%s%s%s
'; + } + + printf( + '

%s

', + esc_html__( 'Legal cells are managed via the REST API. Direct admin editing coming in v0.2.', 'peptide-repo-core' ) + ); + } + + /** + * Save peptide meta fields on post save. + * + * Side effects: updates post meta. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function save_meta( int $post_id, \WP_Post $post ): void { + if ( ! isset( $_POST['pr_core_meta_nonce'] ) ) { + return; + } + + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pr_core_meta_nonce'] ) ), 'pr_core_save_meta' ) ) { + return; + } + + if ( ! current_user_can( PR_Core_Peptide_CPT::CAPABILITY ) ) { + return; + } + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + $meta_fields = PR_Core_Peptide_CPT::get_meta_fields(); + + foreach ( $meta_fields as $key => $config ) { + $input_key = 'pr_core_' . $key; + if ( ! isset( $_POST[ $input_key ] ) ) { + continue; + } + + $raw = wp_unslash( $_POST[ $input_key ] ); + $clean = is_callable( $config['sanitize'] ) + ? call_user_func( $config['sanitize'], $raw ) + : sanitize_text_field( (string) $raw ); + + update_post_meta( $post_id, $key, $clean ); + } + + // Set last_editorial_review_at when status changes to published. + $new_review_status = sanitize_text_field( $_POST['pr_core_editorial_review_status'] ?? '' ); + if ( 'published' === $new_review_status ) { + $old_status = get_post_meta( $post_id, 'editorial_review_status', true ); + if ( $old_status !== 'published' ) { + update_post_meta( $post_id, 'last_editorial_review_at', current_time( 'mysql' ) ); + update_post_meta( $post_id, 'medical_editor_id', get_current_user_id() ); + } + } + + do_action( 'pr_core_after_peptide_publish', $post_id, $post ); + } +} diff --git a/includes/api/class-pr-core-rest-controller.php b/includes/api/class-pr-core-rest-controller.php new file mode 100644 index 0000000..bf7e3b7 --- /dev/null +++ b/includes/api/class-pr-core-rest-controller.php @@ -0,0 +1,234 @@ + 'GET', + 'callback' => [ $this, 'list_peptides' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'category' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + 'evidence_strength' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + 'search' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + 'per_page' => [ 'type' => 'integer', 'default' => 25, 'minimum' => 1, 'maximum' => 100 ], + 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ], + ], + ] ); + + // GET /peptides/{id} + register_rest_route( self::NAMESPACE, '/peptides/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_peptide' ], + 'permission_callback' => '__return_true', + ] ); + + // GET /peptides/{id}/dosing + register_rest_route( self::NAMESPACE, '/peptides/(?P\d+)/dosing', [ + 'methods' => 'GET', + 'callback' => [ $this, 'list_dosing' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'route' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + 'population' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] ); + + // POST /peptides/{id}/dosing (auth required) + register_rest_route( self::NAMESPACE, '/peptides/(?P\d+)/dosing', [ + 'methods' => 'POST', + 'callback' => [ $this, 'create_dosing' ], + 'permission_callback' => [ $this, 'check_write_permission' ], + ] ); + + // GET /peptides/{id}/legal + register_rest_route( self::NAMESPACE, '/peptides/(?P\d+)/legal', [ + 'methods' => 'GET', + 'callback' => [ $this, 'list_legal' ], + 'permission_callback' => '__return_true', + ] ); + + // POST /peptides/{id}/legal (auth required) + register_rest_route( self::NAMESPACE, '/peptides/(?P\d+)/legal', [ + 'methods' => 'POST', + 'callback' => [ $this, 'create_legal' ], + 'permission_callback' => [ $this, 'check_write_permission' ], + ] ); + } + + /** + * List peptides with optional filters. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function list_peptides( \WP_REST_Request $request ): \WP_REST_Response { + $repo = new PR_Core_Peptide_Repository(); + + $search = $request->get_param( 'search' ); + if ( $search ) { + $peptides = $repo->search( $search, (int) $request->get_param( 'per_page' ) ); + } else { + $peptides = $repo->find_all( [ + 'category' => $request->get_param( 'category' ), + 'evidence_strength' => $request->get_param( 'evidence_strength' ), + 'per_page' => $request->get_param( 'per_page' ), + 'page' => $request->get_param( 'page' ), + ] ); + } + + $data = array_map( static fn( PR_Core_Peptide_DTO $p ) => $p->to_array(), $peptides ); + + return new \WP_REST_Response( $data, 200 ); + } + + /** + * Get a single peptide by ID. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public function get_peptide( \WP_REST_Request $request ) { + $repo = new PR_Core_Peptide_Repository(); + $peptide = $repo->find_by_id( (int) $request->get_param( 'id' ) ); + + if ( ! $peptide ) { + return new \WP_Error( 'not_found', __( 'Peptide not found.', 'peptide-repo-core' ), [ 'status' => 404 ] ); + } + + return new \WP_REST_Response( $peptide->to_array(), 200 ); + } + + /** + * List dosing rows for a peptide. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function list_dosing( \WP_REST_Request $request ): \WP_REST_Response { + $repo = new PR_Core_Dosing_Repository(); + $rows = $repo->find_by_peptide( + (int) $request->get_param( 'id' ), + array_filter( [ + 'route' => $request->get_param( 'route' ), + 'population' => $request->get_param( 'population' ), + ] ) + ); + + $data = array_map( static fn( PR_Core_Dosing_Row_DTO $r ) => $r->to_array(), $rows ); + + return new \WP_REST_Response( $data, 200 ); + } + + /** + * Create a new dosing row for a peptide. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public function create_dosing( \WP_REST_Request $request ) { + $repo = new PR_Core_Dosing_Repository(); + + $data = $request->get_json_params(); + $data['peptide_id'] = (int) $request->get_param( 'id' ); + $data['added_by'] = get_current_user_id(); + + $id = $repo->insert( $data ); + + if ( 0 === $id ) { + return new \WP_Error( 'insert_failed', __( 'Failed to insert dosing row.', 'peptide-repo-core' ), [ 'status' => 500 ] ); + } + + $row = $repo->find_by_id( $id ); + + return new \WP_REST_Response( $row ? $row->to_array() : [ 'id' => $id ], 201 ); + } + + /** + * List legal cells for a peptide. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function list_legal( \WP_REST_Request $request ): \WP_REST_Response { + $repo = new PR_Core_Legal_Repository(); + $cells = $repo->find_by_peptide( (int) $request->get_param( 'id' ) ); + + $data = array_map( static fn( PR_Core_Legal_Cell_DTO $c ) => $c->to_array(), $cells ); + + return new \WP_REST_Response( $data, 200 ); + } + + /** + * Create a new legal cell for a peptide. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public function create_legal( \WP_REST_Request $request ) { + $repo = new PR_Core_Legal_Repository(); + + $data = $request->get_json_params(); + $data['peptide_id'] = (int) $request->get_param( 'id' ); + $data['reviewer_id'] = get_current_user_id(); + + $id = $repo->insert( $data ); + + if ( 0 === $id ) { + return new \WP_Error( 'insert_failed', __( 'Failed to insert legal cell.', 'peptide-repo-core' ), [ 'status' => 500 ] ); + } + + $cell = $repo->find_by_id( $id ); + + return new \WP_REST_Response( $cell ? $cell->to_array() : [ 'id' => $id ], 201 ); + } + + /** + * Permission check: user must have manage_peptide_content capability. + * + * @return bool + */ + public function check_write_permission(): bool { + return current_user_can( PR_Core_Peptide_CPT::CAPABILITY ); + } +} diff --git a/includes/class-pr-core-activator.php b/includes/class-pr-core-activator.php new file mode 100644 index 0000000..a4b17b1 --- /dev/null +++ b/includes/class-pr-core-activator.php @@ -0,0 +1,56 @@ +run_pending(); + + // Register CPT so rewrite rules exist before flush. + PR_Core_Peptide_CPT::register_peptide_post_type(); + PR_Core_Peptide_CPT::register_taxonomies(); + + // Grant manage_peptide_content to administrators and editors. + self::add_capabilities(); + + // Flush rewrite rules for the new CPT. + flush_rewrite_rules(); + } + + /** + * Grant the manage_peptide_content capability to appropriate roles. + * + * Side effects: modifies role capabilities in database. + * + * @return void + */ + private static function add_capabilities(): void { + $cap = 'manage_peptide_content'; + $roles = [ 'administrator', 'editor' ]; + + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( $role && ! $role->has_cap( $cap ) ) { + $role->add_cap( $cap ); + } + } + } +} diff --git a/includes/class-pr-core-autoloader.php b/includes/class-pr-core-autoloader.php new file mode 100644 index 0000000..29c5db5 --- /dev/null +++ b/includes/class-pr-core-autoloader.php @@ -0,0 +1,64 @@ + class-pr-core-foo-bar.php. + * Scans includes/ and all immediate subdirectories. + * + * @see peptide-repo-core.php — Registers this autoloader at boot. + */ +class PR_Core_Autoloader { + + /** @var string[] Directories to scan for class files. */ + private static array $dirs = []; + + /** + * Register the autoloader with SPL. + * + * Side effects: calls spl_autoload_register(). + * + * @return void + */ + public static function register(): void { + $base = PR_CORE_PLUGIN_DIR . 'includes/'; + + self::$dirs[] = $base; + + // Add all immediate subdirectories. + foreach ( glob( $base . '*', GLOB_ONLYDIR ) as $dir ) { + self::$dirs[] = trailingslashit( $dir ); + } + + spl_autoload_register( [ __CLASS__, 'load' ] ); + } + + /** + * Autoload callback. Only handles PR_Core_ prefixed classes. + * + * @param string $class Fully-qualified class name. + * @return void + */ + public static function load( string $class ): void { + if ( 0 !== strpos( $class, 'PR_Core_' ) ) { + return; + } + + // PR_Core_Foo_Bar => foo-bar => class-pr-core-foo-bar.php. + $suffix = substr( $class, 8 ); // Strip 'PR_Core_'. + $filename = 'class-pr-core-' . str_replace( '_', '-', strtolower( $suffix ) ) . '.php'; + + foreach ( self::$dirs as $dir ) { + $path = $dir . $filename; + if ( file_exists( $path ) ) { + require_once $path; + return; + } + } + } +} diff --git a/includes/class-pr-core-deactivator.php b/includes/class-pr-core-deactivator.php new file mode 100644 index 0000000..b279b84 --- /dev/null +++ b/includes/class-pr-core-deactivator.php @@ -0,0 +1,26 @@ + count( $peptide_ids ), + 'dosing_rows' => $dosing, + 'legal_cells' => $legal, + ]; + } + + /** + * Create 3 canonical peptide posts. + * + * @return array Map of slug => post ID. + */ + private static function seed_peptides(): array { + $peptides = [ + [ + 'title' => 'BPC-157', + 'slug' => 'bpc-157', + 'excerpt' => 'Body Protection Compound-157, a pentadecapeptide derived from human gastric juice.', + 'display_name' => 'BPC-157', + 'aliases' => '["Body Protection Compound-157","Bepecin","PL 14736","PL-10"]', + 'molecular_formula' => 'C62H98N16O22', + 'molecular_weight' => 1419.53, + 'cas_number' => '137525-51-0', + 'drugbank_id' => '', + 'chembl_id' => '', + 'evidence_strength' => 'observational', + ], + [ + 'title' => 'Semaglutide', + 'slug' => 'semaglutide', + 'excerpt' => 'A GLP-1 receptor agonist approved for type 2 diabetes and chronic weight management.', + 'display_name' => 'Semaglutide', + 'aliases' => '["Ozempic","Wegovy","Rybelsus"]', + 'molecular_formula' => 'C187H291N45O59', + 'molecular_weight' => 4113.58, + 'cas_number' => '910463-68-2', + 'drugbank_id' => 'DB13928', + 'chembl_id' => 'CHEMBL3137309', + 'evidence_strength' => 'meta-analysis', + ], + [ + 'title' => 'TB-500', + 'slug' => 'tb-500', + 'excerpt' => 'Thymosin Beta-4 fragment, a synthetic peptide used in tissue repair research.', + 'display_name' => 'TB-500', + 'aliases' => '["Thymosin Beta-4","Tβ4"]', + 'molecular_formula' => 'C212H350N56O78S', + 'molecular_weight' => 4963.44, + 'cas_number' => '77591-33-4', + 'drugbank_id' => '', + 'chembl_id' => '', + 'evidence_strength' => 'preclinical', + ], + ]; + + $ids = []; + foreach ( $peptides as $p ) { + $existing = get_posts( [ + 'post_type' => PR_Core_Peptide_CPT::POST_TYPE, + 'name' => $p['slug'], + 'posts_per_page' => 1, + 'post_status' => 'any', + ] ); + + if ( ! empty( $existing ) ) { + $ids[ $p['slug'] ] = $existing[0]->ID; + continue; + } + + $post_id = wp_insert_post( [ + 'post_type' => PR_Core_Peptide_CPT::POST_TYPE, + 'post_title' => $p['title'], + 'post_name' => $p['slug'], + 'post_excerpt' => $p['excerpt'], + 'post_status' => 'publish', + 'post_content' => '', + ] ); + + if ( is_wp_error( $post_id ) ) { + continue; + } + + foreach ( [ 'display_name', 'aliases', 'molecular_formula', 'molecular_weight', 'cas_number', 'drugbank_id', 'chembl_id', 'evidence_strength' ] as $key ) { + update_post_meta( $post_id, $key, $p[ $key ] ); + } + update_post_meta( $post_id, 'editorial_review_status', 'published' ); + update_post_meta( $post_id, 'last_editorial_review_at', current_time( 'mysql' ) ); + + $ids[ $p['slug'] ] = $post_id; + } + + return $ids; + } + + /** + * Seed 10 dosing rows across the 3 peptides. + * + * @param array $peptide_ids Map of slug => post ID. + * @return int Number of rows inserted. + */ + private static function seed_dosing_rows( array $peptide_ids ): int { + $repo = new PR_Core_Dosing_Repository(); + $rows = [ + // BPC-157 (4 rows). + [ 'peptide' => 'bpc-157', 'dose_min' => 200, 'dose_max' => 300, 'dose_unit' => 'mcg', 'route' => 'subq', 'frequency' => 'twice daily', 'population' => 'healthy', 'evidence_strength' => 'observational', 'study_title' => 'BPC 157 in wound healing review', 'study_year' => 2021, 'citation_pubmed_id' => '34537800' ], + [ 'peptide' => 'bpc-157', 'dose_min' => 500, 'dose_max' => 500, 'dose_unit' => 'mcg', 'route' => 'subq', 'frequency' => 'daily', 'population' => 'healthy', 'evidence_strength' => 'case-series', 'study_title' => 'Subcutaneous BPC-157 in tendon repair', 'study_year' => 2019, 'citation_pubmed_id' => '30915550' ], + [ 'peptide' => 'bpc-157', 'dose_min' => 10, 'dose_max' => 10, 'dose_unit' => 'mcg', 'route' => 'oral', 'frequency' => 'daily', 'population' => 'animal', 'evidence_strength' => 'preclinical', 'study_title' => 'Oral BPC-157 in rat gastric ulcer model', 'study_year' => 2018, 'citation_pubmed_id' => '29869189' ], + [ 'peptide' => 'bpc-157', 'dose_min' => 250, 'dose_max' => 750, 'dose_unit' => 'mcg', 'route' => 'im', 'frequency' => 'daily', 'population' => 'clinical', 'evidence_strength' => 'case-series', 'study_title' => 'Intramuscular BPC-157 for muscle injuries', 'study_year' => 2020, 'citation_pubmed_id' => '33312000' ], + // Semaglutide (4 rows). + [ 'peptide' => 'semaglutide', 'dose_min' => 0.25, 'dose_max' => 0.25, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'SUSTAIN 1: Semaglutide in T2DM', 'study_year' => 2017, 'citation_pubmed_id' => '28930514', 'indication' => 'Type 2 diabetes — initiation dose' ], + [ 'peptide' => 'semaglutide', 'dose_min' => 1.0, 'dose_max' => 1.0, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'SUSTAIN 6: CV outcomes with semaglutide', 'study_year' => 2016, 'citation_pubmed_id' => '27633186', 'indication' => 'Type 2 diabetes — maintenance' ], + [ 'peptide' => 'semaglutide', 'dose_min' => 2.4, 'dose_max' => 2.4, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'STEP 1: Semaglutide 2.4mg for obesity', 'study_year' => 2021, 'citation_pubmed_id' => '33567185', 'indication' => 'Chronic weight management' ], + [ 'peptide' => 'semaglutide', 'dose_min' => 14, 'dose_max' => 14, 'dose_unit' => 'mg', 'route' => 'oral', 'frequency' => 'daily', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'PIONEER 1: Oral semaglutide in T2DM', 'study_year' => 2019, 'citation_pubmed_id' => '30924169', 'indication' => 'Type 2 diabetes — oral formulation' ], + // TB-500 (2 rows). + [ 'peptide' => 'tb-500', 'dose_min' => 2, 'dose_max' => 2.5, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'twice weekly', 'population' => 'animal', 'evidence_strength' => 'preclinical', 'study_title' => 'Thymosin beta-4 promotes dermal healing in rats', 'study_year' => 2007, 'citation_pubmed_id' => '17584560' ], + [ 'peptide' => 'tb-500', 'dose_min' => 5, 'dose_max' => 5, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'animal', 'evidence_strength' => 'preclinical', 'study_title' => 'TB4 cardiac repair in murine MI model', 'study_year' => 2012, 'citation_pubmed_id' => '22561753' ], + ]; + + $count = 0; + foreach ( $rows as $row ) { + $slug = $row['peptide']; + unset( $row['peptide'] ); + + if ( ! isset( $peptide_ids[ $slug ] ) ) { + continue; + } + + $row['peptide_id'] = $peptide_ids[ $slug ]; + $row['source'] = 'manual'; + $row['added_by'] = get_current_user_id() ?: 1; + + if ( $repo->insert( $row ) > 0 ) { + $count++; + } + } + + return $count; + } + + /** + * Seed 5 legal cells across BPC-157 and Semaglutide. + * + * @param array $peptide_ids Map of slug => post ID. + * @return int Number of cells inserted. + */ + private static function seed_legal_cells( array $peptide_ids ): int { + $repo = new PR_Core_Legal_Repository(); + $cells = [ + [ 'peptide' => 'bpc-157', 'country_code' => 'US', 'status' => 'ruo', 'regulatory_framework' => 'Not FDA-approved; available as Research Use Only', 'notes' => 'Compounding pharmacies may dispense under FDA 503A/503B exemptions.' ], + [ 'peptide' => 'bpc-157', 'country_code' => 'GB', 'status' => 'unclear', 'regulatory_framework' => 'Not listed by MHRA', 'notes' => 'No specific scheduling; import for personal use is a grey area.' ], + [ 'peptide' => 'semaglutide', 'country_code' => 'US', 'status' => 'prescription', 'regulatory_framework' => 'FDA-approved (Ozempic, Wegovy, Rybelsus)', 'regulatory_text_url' => 'https://www.accessdata.fda.gov/drugsatfda_docs/label/2023/209637s020lbl.pdf', 'notes' => 'Schedule IV not applicable; requires valid prescription.' ], + [ 'peptide' => 'semaglutide', 'country_code' => 'GB', 'status' => 'prescription', 'regulatory_framework' => 'MHRA-approved (Ozempic, Wegovy)', 'notes' => 'Available via NHS and private prescription.' ], + [ 'peptide' => 'semaglutide', 'country_code' => 'AU', 'status' => 'prescription', 'regulatory_framework' => 'TGA-approved (Ozempic)', 'notes' => 'PBS-listed for type 2 diabetes; Wegovy approval pending as of 2025.' ], + ]; + + $count = 0; + foreach ( $cells as $cell ) { + $slug = $cell['peptide']; + unset( $cell['peptide'] ); + + if ( ! isset( $peptide_ids[ $slug ] ) ) { + continue; + } + + $cell['peptide_id'] = $peptide_ids[ $slug ]; + $cell['reviewer_id'] = get_current_user_id() ?: 1; + + if ( $repo->insert( $cell ) > 0 ) { + $count++; + } + } + + return $count; + } +} diff --git a/includes/class-pr-core.php b/includes/class-pr-core.php new file mode 100644 index 0000000..aa0f141 --- /dev/null +++ b/includes/class-pr-core.php @@ -0,0 +1,125 @@ +run_pending(); + + // Register CPT + taxonomies (fires on init). + $cpt = new PR_Core_Peptide_CPT(); + $cpt->register_hooks(); + + // Admin UI (fires on admin_init, admin_menu). + if ( is_admin() ) { + $admin = new PR_Core_Admin(); + $admin->register_hooks(); + } + + // Frontend: disclaimer shortcode + JSON-LD. + $disclaimer = new PR_Core_Disclaimer(); + $disclaimer->register_hooks(); + + $jsonld = new PR_Core_Jsonld(); + $jsonld->register_hooks(); + + // REST API. + $rest = new PR_Core_Rest_Controller(); + $rest->register_hooks(); + + // Expose public API filters. + $this->register_public_filters(); + } + + /** + * Register the public filter hooks that consumer plugins rely on. + * + * Filters: + * - pr_core_get_indexable_corpus: Returns indexable content for search plugins. + * - pr_core_disclaimer_for_surface: Returns disclaimer text for a given surface. + * - pr_core_evidence_strength_label: Maps enum to human-readable label. + * + * @return void + */ + private function register_public_filters(): void { + add_filter( 'pr_core_get_indexable_corpus', [ $this, 'filter_indexable_corpus' ] ); + add_filter( 'pr_core_disclaimer_for_surface', [ $this, 'filter_disclaimer_for_surface' ], 10, 2 ); + add_filter( 'pr_core_evidence_strength_label', [ $this, 'filter_evidence_label' ], 10, 2 ); + } + + /** + * Return indexable corpus entries for search plugins (e.g., Peptide Search AI). + * + * Each entry: { id, url, title, body, type }. + * + * @param array> $entries Existing entries from other filters. + * @return array> + */ + public function filter_indexable_corpus( array $entries ): array { + $repo = new PR_Core_Peptide_Repository(); + $peptides = $repo->find_all( [ 'status' => 'publish' ] ); + + foreach ( $peptides as $dto ) { + $entries[] = [ + 'id' => $dto->id, + 'url' => get_permalink( $dto->id ), + 'title' => $dto->title, + 'body' => $dto->excerpt . "\n" . $dto->content, + 'type' => 'peptide_monograph', + ]; + } + + return $entries; + } + + /** + * Return disclaimer text for a given surface identifier. + * + * @param string $text Existing text (empty string default). + * @param string $surface Surface identifier (dosing, legal, reconstitution, ai-answer). + * @return string Disclaimer HTML. + */ + public function filter_disclaimer_for_surface( string $text, string $surface ): string { + return PR_Core_Disclaimer::get_disclaimer_text( $surface ); + } + + /** + * Map evidence_strength enum value to human-readable label. + * + * @param string $label Existing label (empty string default). + * @param string $strength Enum value. + * @return string Localized label. + */ + public function filter_evidence_label( string $label, string $strength ): string { + $map = [ + 'preclinical' => __( 'Preclinical', 'peptide-repo-core' ), + 'case-series' => __( 'Case Series', 'peptide-repo-core' ), + 'observational' => __( 'Observational', 'peptide-repo-core' ), + 'rct-small' => __( 'Small RCT', 'peptide-repo-core' ), + 'rct-large' => __( 'Large RCT', 'peptide-repo-core' ), + 'meta-analysis' => __( 'Meta-Analysis', 'peptide-repo-core' ), + ]; + + return $map[ $strength ] ?? $strength; + } +} diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php new file mode 100644 index 0000000..ed5f255 --- /dev/null +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -0,0 +1,265 @@ + [ 'type', 'default', 'sanitize_callback', 'show_in_rest' ]. + * + * @return array> + */ + public static function get_meta_fields(): array { + return [ + 'display_name' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'aliases' => [ + 'type' => 'string', + 'default' => '[]', + 'sanitize' => [ __CLASS__, 'sanitize_json_array' ], + ], + 'molecular_formula' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'molecular_weight' => [ + 'type' => 'number', + 'default' => 0, + 'sanitize' => 'floatval', + ], + 'cas_number' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'drugbank_id' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'chembl_id' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'evidence_strength' => [ + 'type' => 'string', + 'default' => 'preclinical', + 'sanitize' => [ __CLASS__, 'sanitize_evidence_strength' ], + ], + 'editorial_review_status' => [ + 'type' => 'string', + 'default' => 'draft', + 'sanitize' => [ __CLASS__, 'sanitize_review_status' ], + ], + 'last_editorial_review_at' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'medical_editor_id' => [ + 'type' => 'integer', + 'default' => 0, + 'sanitize' => 'absint', + ], + ]; + } + + /** + * Register WordPress hooks for CPT and taxonomy registration. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'init', [ __CLASS__, 'register_peptide_post_type' ] ); + add_action( 'init', [ __CLASS__, 'register_taxonomies' ] ); + add_action( 'init', [ __CLASS__, 'register_meta_fields' ] ); + } + + /** + * Register the pr_peptide custom post type. + * + * Side effects: registers CPT with WordPress. + * + * @return void + */ + public static function register_peptide_post_type(): void { + $labels = [ + 'name' => __( 'Peptides', 'peptide-repo-core' ), + 'singular_name' => __( 'Peptide', 'peptide-repo-core' ), + 'add_new_item' => __( 'Add New Peptide', 'peptide-repo-core' ), + 'edit_item' => __( 'Edit Peptide', 'peptide-repo-core' ), + 'new_item' => __( 'New Peptide', 'peptide-repo-core' ), + 'view_item' => __( 'View Peptide', 'peptide-repo-core' ), + 'search_items' => __( 'Search Peptides', 'peptide-repo-core' ), + 'not_found' => __( 'No peptides found', 'peptide-repo-core' ), + 'not_found_in_trash' => __( 'No peptides found in trash', 'peptide-repo-core' ), + 'all_items' => __( 'All Peptides', 'peptide-repo-core' ), + 'menu_name' => __( 'Peptides', 'peptide-repo-core' ), + ]; + + $args = [ + 'labels' => $labels, + 'public' => true, + 'has_archive' => true, + 'rewrite' => [ 'slug' => 'peptides', 'with_front' => false ], + 'show_in_rest' => true, + 'rest_base' => 'peptides', + 'rest_namespace' => 'pr-core/v1', + 'supports' => [ 'title', 'editor', 'excerpt', 'revisions', 'custom-fields' ], + 'menu_icon' => 'dashicons-database', + 'capability_type' => 'post', + 'map_meta_cap' => true, + 'show_in_menu' => true, + 'menu_position' => 25, + ]; + + register_post_type( self::POST_TYPE, $args ); + } + + /** + * Register the pr_peptide_category and pr_peptide_family taxonomies. + * + * Side effects: registers taxonomies with WordPress. + * + * @return void + */ + public static function register_taxonomies(): void { + register_taxonomy( self::TAX_CATEGORY, self::POST_TYPE, [ + 'labels' => [ + 'name' => __( 'Peptide Categories', 'peptide-repo-core' ), + 'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ), + ], + 'hierarchical' => true, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rewrite' => [ 'slug' => 'peptide-category' ], + ] ); + + register_taxonomy( self::TAX_FAMILY, self::POST_TYPE, [ + 'labels' => [ + 'name' => __( 'Peptide Families', 'peptide-repo-core' ), + 'singular_name' => __( 'Peptide Family', 'peptide-repo-core' ), + ], + 'hierarchical' => false, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rewrite' => [ 'slug' => 'peptide-family' ], + ] ); + } + + /** + * Register post-meta fields with the REST API. + * + * Side effects: registers meta with WordPress. + * + * @return void + */ + public static function register_meta_fields(): void { + foreach ( self::get_meta_fields() as $key => $config ) { + register_post_meta( self::POST_TYPE, $key, [ + 'type' => $config['type'], + 'single' => true, + 'default' => $config['default'], + 'show_in_rest' => true, + 'sanitize_callback' => $config['sanitize'], + 'auth_callback' => static function () { + return current_user_can( self::CAPABILITY ); + }, + ] ); + } + } + + /** + * Sanitize a JSON array string (e.g., aliases field). + * + * @param mixed $value Raw input. + * @return string Valid JSON array string. + */ + public static function sanitize_json_array( $value ): string { + if ( is_array( $value ) ) { + $value = wp_json_encode( array_map( 'sanitize_text_field', $value ) ); + } + + $decoded = json_decode( (string) $value, true ); + if ( ! is_array( $decoded ) ) { + return '[]'; + } + + return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) ); + } + + /** + * Sanitize evidence_strength to allowed enum values. + * + * @param mixed $value Raw input. + * @return string Valid enum value. + */ + public static function sanitize_evidence_strength( $value ): string { + $value = sanitize_text_field( (string) $value ); + return in_array( $value, self::EVIDENCE_STRENGTHS, true ) ? $value : 'preclinical'; + } + + /** + * Sanitize editorial_review_status to allowed enum values. + * + * @param mixed $value Raw input. + * @return string Valid enum value. + */ + public static function sanitize_review_status( $value ): string { + $value = sanitize_text_field( (string) $value ); + return in_array( $value, self::REVIEW_STATUSES, true ) ? $value : 'draft'; + } +} diff --git a/includes/dto/class-pr-core-candidate-dto.php b/includes/dto/class-pr-core-candidate-dto.php new file mode 100644 index 0000000..9183d9e --- /dev/null +++ b/includes/dto/class-pr-core-candidate-dto.php @@ -0,0 +1,95 @@ + $data Associative array from database row. + */ + public function __construct( array $data ) { + $this->id = (int) ( $data['id'] ?? 0 ); + $this->peptide_id = (int) ( $data['peptide_id'] ?? 0 ); + $this->dose_min = isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null; + $this->dose_max = isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null; + $this->dose_unit = (string) ( $data['dose_unit'] ?? '' ); + $this->route = (string) ( $data['route'] ?? '' ); + $this->frequency = $data['frequency'] ?? null; + $this->duration_value = isset( $data['duration_value'] ) ? (int) $data['duration_value'] : null; + $this->duration_unit = $data['duration_unit'] ?? null; + $this->population = (string) ( $data['population'] ?? '' ); + $this->indication = $data['indication'] ?? null; + $this->evidence_strength = (string) ( $data['evidence_strength'] ?? 'preclinical' ); + $this->study_title = $data['study_title'] ?? null; + $this->study_year = isset( $data['study_year'] ) ? (int) $data['study_year'] : null; + $this->citation_pubmed_id = $data['citation_pubmed_id'] ?? null; + $this->citation_doi = $data['citation_doi'] ?? null; + $this->citation_url = $data['citation_url'] ?? null; + $this->notes = $data['notes'] ?? null; + $this->extraction_confidence = (float) ( $data['extraction_confidence'] ?? 0 ); + $this->queue_status = (string) ( $data['queue_status'] ?? 'pending' ); + $this->extracted_at = (string) ( $data['extracted_at'] ?? '' ); + $this->reviewed_by = isset( $data['reviewed_by'] ) ? (int) $data['reviewed_by'] : null; + $this->reviewed_at = $data['reviewed_at'] ?? null; + $this->reviewer_notes = $data['reviewer_notes'] ?? null; + } + + /** + * Convert to associative array for admin UI output. + * + * @return array + */ + public function to_array(): array { + return [ + 'id' => $this->id, + 'peptide_id' => $this->peptide_id, + 'dose_min' => $this->dose_min, + 'dose_max' => $this->dose_max, + 'dose_unit' => $this->dose_unit, + 'route' => $this->route, + 'frequency' => $this->frequency, + 'population' => $this->population, + 'indication' => $this->indication, + 'evidence_strength' => $this->evidence_strength, + 'study_title' => $this->study_title, + 'citation_pubmed_id' => $this->citation_pubmed_id, + 'extraction_confidence' => $this->extraction_confidence, + 'queue_status' => $this->queue_status, + 'extracted_at' => $this->extracted_at, + ]; + } +} diff --git a/includes/dto/class-pr-core-dosing-row-dto.php b/includes/dto/class-pr-core-dosing-row-dto.php new file mode 100644 index 0000000..98724b5 --- /dev/null +++ b/includes/dto/class-pr-core-dosing-row-dto.php @@ -0,0 +1,104 @@ + $data Associative array from database row. + */ + public function __construct( array $data ) { + $this->id = (int) ( $data['id'] ?? 0 ); + $this->peptide_id = (int) ( $data['peptide_id'] ?? 0 ); + $this->dose_min = isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null; + $this->dose_max = isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null; + $this->dose_unit = (string) ( $data['dose_unit'] ?? '' ); + $this->route = (string) ( $data['route'] ?? '' ); + $this->frequency = $data['frequency'] ?? null; + $this->duration_value = isset( $data['duration_value'] ) ? (int) $data['duration_value'] : null; + $this->duration_unit = $data['duration_unit'] ?? null; + $this->population = (string) ( $data['population'] ?? '' ); + $this->indication = $data['indication'] ?? null; + $this->evidence_strength = (string) ( $data['evidence_strength'] ?? 'preclinical' ); + $this->study_title = $data['study_title'] ?? null; + $this->study_year = isset( $data['study_year'] ) ? (int) $data['study_year'] : null; + $this->citation_pubmed_id = $data['citation_pubmed_id'] ?? null; + $this->citation_doi = $data['citation_doi'] ?? null; + $this->citation_url = $data['citation_url'] ?? null; + $this->notes = $data['notes'] ?? null; + $this->schema_version = (int) ( $data['schema_version'] ?? 1 ); + $this->source = (string) ( $data['source'] ?? 'manual' ); + $this->ai_candidate_id = isset( $data['ai_candidate_id'] ) ? (int) $data['ai_candidate_id'] : null; + $this->added_by = (int) ( $data['added_by'] ?? 0 ); + $this->added_at = (string) ( $data['added_at'] ?? '' ); + $this->reviewed_by = isset( $data['reviewed_by'] ) ? (int) $data['reviewed_by'] : null; + $this->reviewed_at = $data['reviewed_at'] ?? null; + $this->superseded_by_id = isset( $data['superseded_by_id'] ) ? (int) $data['superseded_by_id'] : null; + } + + /** + * Convert to associative array for REST/admin output. + * + * @return array + */ + public function to_array(): array { + return [ + 'id' => $this->id, + 'peptide_id' => $this->peptide_id, + 'dose_min' => $this->dose_min, + 'dose_max' => $this->dose_max, + 'dose_unit' => $this->dose_unit, + 'route' => $this->route, + 'frequency' => $this->frequency, + 'duration_value' => $this->duration_value, + 'duration_unit' => $this->duration_unit, + 'population' => $this->population, + 'indication' => $this->indication, + 'evidence_strength' => $this->evidence_strength, + 'study_title' => $this->study_title, + 'study_year' => $this->study_year, + 'citation_pubmed_id' => $this->citation_pubmed_id, + 'citation_doi' => $this->citation_doi, + 'citation_url' => $this->citation_url, + 'notes' => $this->notes, + 'source' => $this->source, + 'added_at' => $this->added_at, + ]; + } +} diff --git a/includes/dto/class-pr-core-legal-cell-dto.php b/includes/dto/class-pr-core-legal-cell-dto.php new file mode 100644 index 0000000..ea0b8d0 --- /dev/null +++ b/includes/dto/class-pr-core-legal-cell-dto.php @@ -0,0 +1,75 @@ + $data Associative array from database row. + */ + public function __construct( array $data ) { + $this->id = (int) ( $data['id'] ?? 0 ); + $this->peptide_id = (int) ( $data['peptide_id'] ?? 0 ); + $this->country_code = (string) ( $data['country_code'] ?? '' ); + $this->status = (string) ( $data['status'] ?? 'unclear' ); + $this->regulatory_framework = $data['regulatory_framework'] ?? null; + $this->regulatory_text_url = $data['regulatory_text_url'] ?? null; + $this->regulatory_text_quote = $data['regulatory_text_quote'] ?? null; + $this->notes = $data['notes'] ?? null; + $this->last_verified_at = (string) ( $data['last_verified_at'] ?? '' ); + $this->schema_version = (int) ( $data['schema_version'] ?? 1 ); + $this->reviewer_id = (int) ( $data['reviewer_id'] ?? 0 ); + $this->superseded_by_id = isset( $data['superseded_by_id'] ) ? (int) $data['superseded_by_id'] : null; + } + + /** + * Check if this cell is the active (non-superseded) version. + * + * @return bool + */ + public function is_active(): bool { + return null === $this->superseded_by_id; + } + + /** + * Convert to associative array for REST/admin output. + * + * @return array + */ + public function to_array(): array { + return [ + 'id' => $this->id, + 'peptide_id' => $this->peptide_id, + 'country_code' => $this->country_code, + 'status' => $this->status, + 'regulatory_framework' => $this->regulatory_framework, + 'regulatory_text_url' => $this->regulatory_text_url, + 'regulatory_text_quote' => $this->regulatory_text_quote, + 'notes' => $this->notes, + 'last_verified_at' => $this->last_verified_at, + 'is_active' => $this->is_active(), + ]; + } +} diff --git a/includes/dto/class-pr-core-peptide-dto.php b/includes/dto/class-pr-core-peptide-dto.php new file mode 100644 index 0000000..c6f3c21 --- /dev/null +++ b/includes/dto/class-pr-core-peptide-dto.php @@ -0,0 +1,91 @@ + $data Associative array of peptide fields. + */ + public function __construct( array $data ) { + $this->id = (int) ( $data['id'] ?? 0 ); + $this->title = (string) ( $data['title'] ?? '' ); + $this->slug = (string) ( $data['slug'] ?? '' ); + $this->content = (string) ( $data['content'] ?? '' ); + $this->excerpt = (string) ( $data['excerpt'] ?? '' ); + $this->status = (string) ( $data['status'] ?? 'draft' ); + $this->display_name = (string) ( $data['display_name'] ?? '' ); + $this->aliases = (array) ( $data['aliases'] ?? [] ); + $this->molecular_formula = (string) ( $data['molecular_formula'] ?? '' ); + $this->molecular_weight = (float) ( $data['molecular_weight'] ?? 0 ); + $this->cas_number = (string) ( $data['cas_number'] ?? '' ); + $this->drugbank_id = (string) ( $data['drugbank_id'] ?? '' ); + $this->chembl_id = (string) ( $data['chembl_id'] ?? '' ); + $this->evidence_strength = (string) ( $data['evidence_strength'] ?? 'preclinical' ); + $this->editorial_review_status = (string) ( $data['editorial_review_status'] ?? 'draft' ); + $this->last_editorial_review_at = (string) ( $data['last_editorial_review_at'] ?? '' ); + $this->medical_editor_id = (int) ( $data['medical_editor_id'] ?? 0 ); + $this->categories = (array) ( $data['categories'] ?? [] ); + $this->families = (array) ( $data['families'] ?? [] ); + } + + /** + * Convert to associative array (e.g., for REST API responses). + * + * @return array + */ + public function to_array(): array { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'slug' => $this->slug, + 'excerpt' => $this->excerpt, + 'display_name' => $this->display_name, + 'aliases' => $this->aliases, + 'molecular_formula' => $this->molecular_formula, + 'molecular_weight' => $this->molecular_weight, + 'cas_number' => $this->cas_number, + 'drugbank_id' => $this->drugbank_id, + 'chembl_id' => $this->chembl_id, + 'evidence_strength' => $this->evidence_strength, + 'editorial_review_status' => $this->editorial_review_status, + 'last_editorial_review_at' => $this->last_editorial_review_at, + 'medical_editor_id' => $this->medical_editor_id, + 'categories' => $this->categories, + 'families' => $this->families, + 'url' => get_permalink( $this->id ), + ]; + } +} diff --git a/includes/frontend/class-pr-core-disclaimer.php b/includes/frontend/class-pr-core-disclaimer.php new file mode 100644 index 0000000..e46f7b9 --- /dev/null +++ b/includes/frontend/class-pr-core-disclaimer.php @@ -0,0 +1,120 @@ + + */ + private const DEFAULTS = [ + 'dosing' => 'The dosing information presented here is compiled from published peer-reviewed literature and is provided for educational and research purposes only. It does not constitute medical advice, diagnosis, or treatment recommendations. Always consult a licensed healthcare professional before making any decisions based on this information.', + 'legal' => 'Legal status information is compiled from publicly available regulatory texts and is current as of the last-verified date shown. Regulatory frameworks change frequently. This information does not constitute legal advice. Verify current regulations with the relevant regulatory authority in your jurisdiction before taking any action.', + 'reconstitution' => 'This calculator is provided for educational purposes only. Always verify your calculations independently and consult a licensed healthcare professional. The results are based on the inputs you provide and standard reconstitution mathematics.', + 'ai-answer' => 'This response was generated by an AI system and may contain errors or omissions. All claims are grounded in the Peptide Repo corpus and cited sources where possible. This is not medical advice. Always verify information with primary sources and consult a licensed healthcare professional.', + 'general' => 'The information on this site is for educational and research purposes only. It is not intended as medical advice, diagnosis, or treatment. Always consult a licensed healthcare professional.', + ]; + + /** + * Register shortcode and any frontend hooks. + * + * @return void + */ + public function register_hooks(): void { + add_shortcode( 'pr_disclaimer', [ $this, 'shortcode_handler' ] ); + } + + /** + * Shortcode handler: [pr_disclaimer surface="dosing"]. + * + * @param array|string $atts Shortcode attributes. + * @return string Disclaimer HTML. + */ + public function shortcode_handler( $atts ): string { + $atts = shortcode_atts( [ 'surface' => 'general' ], $atts, 'pr_disclaimer' ); + return self::render( sanitize_text_field( $atts['surface'] ) ); + } + + /** + * Render disclaimer HTML for a given surface. + * + * @param string $surface Surface identifier. + * @return string HTML output. + */ + public static function render( string $surface ): string { + $text = self::get_disclaimer_text( $surface ); + + return sprintf( + '

%s

', + esc_attr( $surface ), + esc_attr__( 'Disclaimer', 'peptide-repo-core' ), + esc_html( $text ) + ); + } + + /** + * Get disclaimer text for a surface. Checks custom copy first, falls back to defaults. + * + * @param string $surface Surface identifier. + * @return string Disclaimer text. + */ + public static function get_disclaimer_text( string $surface ): string { + $custom = get_option( self::COPY_OPTION, '{}' ); + $copy = json_decode( $custom, true ) ?: []; + + if ( ! empty( $copy[ $surface ] ) ) { + return (string) $copy[ $surface ]; + } + + return self::DEFAULTS[ $surface ] ?? self::DEFAULTS['general']; + } + + /** + * Get current disclaimer version. + * + * @return int + */ + public static function get_version(): int { + return (int) get_option( self::VERSION_OPTION, 1 ); + } + + /** + * Update disclaimer copy for a surface. + * + * Side effects: updates wp_options. + * + * @param string $surface Surface identifier. + * @param string $text New disclaimer text. + * @return void + */ + public static function update_disclaimer( string $surface, string $text ): void { + $custom = get_option( self::COPY_OPTION, '{}' ); + $copy = json_decode( $custom, true ) ?: []; + + $copy[ sanitize_text_field( $surface ) ] = sanitize_textarea_field( $text ); + + update_option( self::COPY_OPTION, wp_json_encode( $copy ) ); + update_option( self::VERSION_OPTION, self::get_version() + 1 ); + } +} diff --git a/includes/frontend/class-pr-core-jsonld.php b/includes/frontend/class-pr-core-jsonld.php new file mode 100644 index 0000000..febc3a9 --- /dev/null +++ b/includes/frontend/class-pr-core-jsonld.php @@ -0,0 +1,125 @@ +find_by_id( $post_id ); + + if ( ! $peptide ) { + return; + } + + $schema = $this->build_drug_schema( $peptide ); + + /** + * Filter the JSON-LD schema for a peptide page. + * + * @param array $schema Schema.org data array. + * @param PR_Core_Peptide_DTO $peptide The peptide DTO. + */ + $schema = apply_filters( 'pr_core_jsonld_peptide', $schema, $peptide ); + + if ( empty( $schema ) ) { + return; + } + + printf( + '' . "\n", + wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ) + ); + } + + /** + * Build a schema.org Drug object for a peptide. + * + * @param PR_Core_Peptide_DTO $peptide Peptide data. + * @return array Schema.org JSON-LD structure. + */ + private function build_drug_schema( PR_Core_Peptide_DTO $peptide ): array { + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'Drug', + 'name' => $peptide->display_name ?: $peptide->title, + 'url' => get_permalink( $peptide->id ), + 'description' => $peptide->excerpt, + ]; + + if ( ! empty( $peptide->aliases ) ) { + $schema['alternateName'] = $peptide->aliases; + } + + if ( '' !== $peptide->molecular_formula ) { + $schema['molecularFormula'] = $peptide->molecular_formula; + } + + if ( $peptide->molecular_weight > 0 ) { + $schema['molecularWeight'] = [ + '@type' => 'QuantitativeValue', + 'value' => $peptide->molecular_weight, + 'unitText' => 'Da', + ]; + } + + // External identifiers as codes. + $codes = []; + if ( '' !== $peptide->cas_number ) { + $codes[] = [ + '@type' => 'MedicalCode', + 'codeValue' => $peptide->cas_number, + 'codingSystem' => 'CAS', + ]; + } + + if ( '' !== $peptide->drugbank_id ) { + $codes[] = [ + '@type' => 'MedicalCode', + 'codeValue' => $peptide->drugbank_id, + 'codingSystem' => 'DrugBank', + ]; + } + + if ( ! empty( $codes ) ) { + $schema['code'] = $codes; + } + + return $schema; + } +} diff --git a/includes/migrations/class-pr-core-migration-0001-dosing-rows.php b/includes/migrations/class-pr-core-migration-0001-dosing-rows.php new file mode 100644 index 0000000..3e069dd --- /dev/null +++ b/includes/migrations/class-pr-core-migration-0001-dosing-rows.php @@ -0,0 +1,63 @@ +prefix . 'pr_dosing_rows'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + peptide_id BIGINT UNSIGNED NOT NULL, + dose_min DECIMAL(12,4) NULL, + dose_max DECIMAL(12,4) NULL, + dose_unit VARCHAR(16) NOT NULL, + route VARCHAR(32) NOT NULL, + frequency VARCHAR(64) NULL, + duration_value INT UNSIGNED NULL, + duration_unit VARCHAR(16) NULL, + population VARCHAR(32) NOT NULL, + indication VARCHAR(255) NULL, + evidence_strength VARCHAR(32) NOT NULL, + study_title VARCHAR(500) NULL, + study_year SMALLINT UNSIGNED NULL, + citation_pubmed_id VARCHAR(16) NULL, + citation_doi VARCHAR(128) NULL, + citation_url VARCHAR(500) NULL, + notes TEXT NULL, + schema_version SMALLINT UNSIGNED NOT NULL DEFAULT 1, + source VARCHAR(32) NOT NULL DEFAULT 'manual', + ai_candidate_id BIGINT UNSIGNED NULL, + added_by BIGINT UNSIGNED NOT NULL, + added_at DATETIME NOT NULL, + reviewed_by BIGINT UNSIGNED NULL, + reviewed_at DATETIME NULL, + superseded_by_id BIGINT UNSIGNED NULL, + PRIMARY KEY (id), + KEY idx_peptide (peptide_id), + KEY idx_peptide_route (peptide_id, route), + KEY idx_peptide_population (peptide_id, population), + KEY idx_pubmed (citation_pubmed_id) + ) {$charset};"; + + dbDelta( $sql ); + } +} diff --git a/includes/migrations/class-pr-core-migration-0002-legal-cells.php b/includes/migrations/class-pr-core-migration-0002-legal-cells.php new file mode 100644 index 0000000..c3f5564 --- /dev/null +++ b/includes/migrations/class-pr-core-migration-0002-legal-cells.php @@ -0,0 +1,78 @@ +prefix . 'pr_legal_cells'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + peptide_id BIGINT UNSIGNED NOT NULL, + country_code CHAR(2) NOT NULL, + status VARCHAR(32) NOT NULL, + regulatory_framework VARCHAR(128) NULL, + regulatory_text_url VARCHAR(500) NULL, + regulatory_text_quote TEXT NULL, + notes TEXT NULL, + last_verified_at DATETIME NOT NULL, + schema_version SMALLINT UNSIGNED NOT NULL DEFAULT 1, + reviewer_id BIGINT UNSIGNED NOT NULL, + superseded_by_id BIGINT UNSIGNED NULL, + PRIMARY KEY (id), + KEY idx_peptide (peptide_id), + KEY idx_country (country_code) + ) {$charset};"; + + dbDelta( $sql ); + + // dbDelta doesn't support UNIQUE with nullable columns well. + // Add the unique constraint manually if not present. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $index_exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s", + DB_NAME, + $table, + 'uq_peptide_country_active' + ) + ); + + if ( '0' === $index_exists ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + "ALTER TABLE {$table} ADD UNIQUE KEY uq_peptide_country_active (peptide_id, country_code, superseded_by_id)" + ); + } + } +} diff --git a/includes/migrations/class-pr-core-migration-0003-candidate-queue.php b/includes/migrations/class-pr-core-migration-0003-candidate-queue.php new file mode 100644 index 0000000..e8665ae --- /dev/null +++ b/includes/migrations/class-pr-core-migration-0003-candidate-queue.php @@ -0,0 +1,72 @@ +prefix . 'pr_ai_candidate_queue'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + peptide_id BIGINT UNSIGNED NOT NULL, + dose_min DECIMAL(12,4) NULL, + dose_max DECIMAL(12,4) NULL, + dose_unit VARCHAR(16) NOT NULL, + route VARCHAR(32) NOT NULL, + frequency VARCHAR(64) NULL, + duration_value INT UNSIGNED NULL, + duration_unit VARCHAR(16) NULL, + population VARCHAR(32) NOT NULL, + indication VARCHAR(255) NULL, + evidence_strength VARCHAR(32) NOT NULL, + study_title VARCHAR(500) NULL, + study_year SMALLINT UNSIGNED NULL, + citation_pubmed_id VARCHAR(16) NULL, + citation_doi VARCHAR(128) NULL, + citation_url VARCHAR(500) NULL, + notes TEXT NULL, + extraction_confidence FLOAT NOT NULL DEFAULT 0, + queue_status VARCHAR(16) NOT NULL DEFAULT 'pending', + extracted_at DATETIME NOT NULL, + reviewed_by BIGINT UNSIGNED NULL, + reviewed_at DATETIME NULL, + reviewer_notes TEXT NULL, + PRIMARY KEY (id), + KEY idx_peptide (peptide_id), + KEY idx_status (queue_status), + KEY idx_confidence (extraction_confidence), + KEY idx_peptide_status (peptide_id, queue_status) + ) {$charset};"; + + dbDelta( $sql ); + } +} diff --git a/includes/migrations/class-pr-core-migration-runner.php b/includes/migrations/class-pr-core-migration-runner.php new file mode 100644 index 0000000..55c536a --- /dev/null +++ b/includes/migrations/class-pr-core-migration-runner.php @@ -0,0 +1,97 @@ += $target ) { + return; + } + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + for ( $i = $current; $i < $target; $i++ ) { + if ( ! isset( self::MIGRATIONS[ $i ] ) ) { + break; + } + + $class = self::MIGRATIONS[ $i ]; + if ( ! class_exists( $class ) ) { + // Migration file not autoloaded — load explicitly. + $file = $this->class_to_file( $class ); + if ( file_exists( $file ) ) { + require_once $file; + } + } + + if ( class_exists( $class ) ) { + $migration = new $class(); + $migration->up(); + } + + update_option( self::VERSION_OPTION, $i + 1 ); + } + } + + /** + * Get current schema version. + * + * @return int + */ + public function get_current_version(): int { + return (int) get_option( self::VERSION_OPTION, 0 ); + } + + /** + * Convert migration class name to file path. + * + * PR_Core_Migration_0001_Dosing_Rows => migrations/class-pr-core-migration-0001-dosing-rows.php + * + * @param string $class Fully-qualified class name. + * @return string Absolute file path. + */ + private function class_to_file( string $class ): string { + $suffix = substr( $class, 8 ); // Strip 'PR_Core_'. + $filename = 'class-pr-core-' . str_replace( '_', '-', strtolower( $suffix ) ) . '.php'; + + return PR_CORE_PLUGIN_DIR . 'includes/migrations/' . $filename; + } +} diff --git a/includes/repositories/class-pr-core-candidate-queue-repository.php b/includes/repositories/class-pr-core-candidate-queue-repository.php new file mode 100644 index 0000000..b66beac --- /dev/null +++ b/includes/repositories/class-pr-core-candidate-queue-repository.php @@ -0,0 +1,240 @@ + Review (approve/reject) -> Merge into dosing rows. + * + * @see migrations/class-pr-core-migration-0003-candidate-queue.php — Table schema. + * @see dto/class-pr-core-candidate-dto.php — Return type. + * @see admin/class-pr-core-candidate-queue-page.php — Admin UI. + */ +class PR_Core_Candidate_Queue_Repository { + + /** + * Find a single candidate by ID. + * + * @param int $id Row ID. + * @return PR_Core_Candidate_DTO|null + */ + public function find_by_id( int $id ): ?PR_Core_Candidate_DTO { + global $wpdb; + $table = $wpdb->prefix . 'pr_ai_candidate_queue'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), + ARRAY_A + ); + + return $row ? new PR_Core_Candidate_DTO( $row ) : null; + } + + /** + * Find candidates by status, optionally filtered by peptide. + * + * @param string $status Queue status: pending, approved, rejected, merged. + * @param int $peptide_id Optional peptide filter (0 = all). + * @param int $limit Max results. + * @return PR_Core_Candidate_DTO[] + */ + public function find_by_status( string $status = 'pending', int $peptide_id = 0, int $limit = 50 ): array { + global $wpdb; + $table = $wpdb->prefix . 'pr_ai_candidate_queue'; + + $where = [ 'queue_status = %s' ]; + $params = [ sanitize_text_field( $status ) ]; + + if ( $peptide_id > 0 ) { + $where[] = 'peptide_id = %d'; + $params[] = $peptide_id; + } + + $params[] = $limit; + $where_sql = implode( ' AND ', $where ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY extraction_confidence DESC LIMIT %d", + ...$params + ), + ARRAY_A + ) ?: []; + + return array_map( + static fn( array $row ) => new PR_Core_Candidate_DTO( $row ), + $rows + ); + } + + /** + * Insert a new candidate into the queue. + * + * Side effects: database insert. + * + * @param array $data Candidate data. + * @return int Inserted row ID, or 0 on failure. + */ + public function insert( array $data ): int { + global $wpdb; + $table = $wpdb->prefix . 'pr_ai_candidate_queue'; + + $row = [ + 'peptide_id' => absint( $data['peptide_id'] ?? 0 ), + 'dose_min' => isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null, + 'dose_max' => isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null, + 'dose_unit' => sanitize_text_field( $data['dose_unit'] ?? '' ), + 'route' => sanitize_text_field( $data['route'] ?? '' ), + 'frequency' => isset( $data['frequency'] ) ? sanitize_text_field( $data['frequency'] ) : null, + 'duration_value' => isset( $data['duration_value'] ) ? absint( $data['duration_value'] ) : null, + 'duration_unit' => isset( $data['duration_unit'] ) ? sanitize_text_field( $data['duration_unit'] ) : null, + 'population' => sanitize_text_field( $data['population'] ?? '' ), + 'indication' => isset( $data['indication'] ) ? sanitize_text_field( $data['indication'] ) : null, + 'evidence_strength' => PR_Core_Peptide_CPT::sanitize_evidence_strength( $data['evidence_strength'] ?? '' ), + 'study_title' => isset( $data['study_title'] ) ? sanitize_text_field( $data['study_title'] ) : null, + 'study_year' => isset( $data['study_year'] ) ? absint( $data['study_year'] ) : null, + 'citation_pubmed_id' => isset( $data['citation_pubmed_id'] ) ? sanitize_text_field( $data['citation_pubmed_id'] ) : null, + 'citation_doi' => isset( $data['citation_doi'] ) ? sanitize_text_field( $data['citation_doi'] ) : null, + 'citation_url' => isset( $data['citation_url'] ) ? esc_url_raw( $data['citation_url'] ) : null, + 'notes' => isset( $data['notes'] ) ? sanitize_textarea_field( $data['notes'] ) : null, + 'extraction_confidence' => (float) ( $data['extraction_confidence'] ?? 0 ), + 'queue_status' => 'pending', + 'extracted_at' => current_time( 'mysql' ), + ]; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( $table, $row ); + + return false !== $result ? (int) $wpdb->insert_id : 0; + } + + /** + * Approve a candidate: copy to dosing rows, mark as merged. + * + * Fires pr_core_candidate_approved action. + * + * Side effects: database insert (dosing row) + update (queue row). + * + * @param int $candidate_id Queue row ID. + * @param int $reviewer_id User ID of the reviewer. + * @param string $notes Optional reviewer notes. + * @return int New dosing row ID, or 0 on failure. + */ + public function approve( int $candidate_id, int $reviewer_id, string $notes = '' ): int { + $candidate = $this->find_by_id( $candidate_id ); + if ( ! $candidate || 'pending' !== $candidate->queue_status ) { + return 0; + } + + // Copy candidate data into a dosing row. + $dosing_repo = new PR_Core_Dosing_Repository(); + $dosing_id = $dosing_repo->insert( [ + 'peptide_id' => $candidate->peptide_id, + 'dose_min' => $candidate->dose_min, + 'dose_max' => $candidate->dose_max, + 'dose_unit' => $candidate->dose_unit, + 'route' => $candidate->route, + 'frequency' => $candidate->frequency, + 'duration_value' => $candidate->duration_value, + 'duration_unit' => $candidate->duration_unit, + 'population' => $candidate->population, + 'indication' => $candidate->indication, + 'evidence_strength' => $candidate->evidence_strength, + 'study_title' => $candidate->study_title, + 'study_year' => $candidate->study_year, + 'citation_pubmed_id' => $candidate->citation_pubmed_id, + 'citation_doi' => $candidate->citation_doi, + 'citation_url' => $candidate->citation_url, + 'notes' => $candidate->notes, + 'source' => 'ai-candidate-approved', + 'ai_candidate_id' => $candidate_id, + 'added_by' => $reviewer_id, + 'reviewed_by' => $reviewer_id, + 'reviewed_at' => current_time( 'mysql' ), + ] ); + + if ( 0 === $dosing_id ) { + return 0; + } + + $this->update_status( $candidate_id, 'merged', $reviewer_id, $notes ); + + do_action( 'pr_core_candidate_approved', $candidate_id, $dosing_id ); + + return $dosing_id; + } + + /** + * Reject a candidate. + * + * Fires pr_core_candidate_rejected action. + * + * Side effects: database update. + * + * @param int $candidate_id Queue row ID. + * @param int $reviewer_id User ID. + * @param string $notes Rejection reason. + * @return bool + */ + public function reject( int $candidate_id, int $reviewer_id, string $notes = '' ): bool { + $result = $this->update_status( $candidate_id, 'rejected', $reviewer_id, $notes ); + + if ( $result ) { + do_action( 'pr_core_candidate_rejected', $candidate_id ); + } + + return $result; + } + + /** + * Count candidates by status. + * + * @param string $status Queue status. + * @return int + */ + public function count_by_status( string $status = 'pending' ): int { + global $wpdb; + $table = $wpdb->prefix . 'pr_ai_candidate_queue'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE queue_status = %s", $status ) + ); + } + + /** + * Update a candidate's queue status. + * + * @param int $id Row ID. + * @param string $status New status. + * @param int $reviewer_id Reviewer user ID. + * @param string $notes Optional notes. + * @return bool + */ + private function update_status( int $id, string $status, int $reviewer_id, string $notes ): bool { + global $wpdb; + $table = $wpdb->prefix . 'pr_ai_candidate_queue'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->update( + $table, + [ + 'queue_status' => sanitize_text_field( $status ), + 'reviewed_by' => $reviewer_id, + 'reviewed_at' => current_time( 'mysql' ), + 'reviewer_notes' => sanitize_textarea_field( $notes ), + ], + [ 'id' => $id ], + [ '%s', '%d', '%s', '%s' ], + [ '%d' ] + ); + + return false !== $result; + } +} diff --git a/includes/repositories/class-pr-core-dosing-repository.php b/includes/repositories/class-pr-core-dosing-repository.php new file mode 100644 index 0000000..1a9934e --- /dev/null +++ b/includes/repositories/class-pr-core-dosing-repository.php @@ -0,0 +1,200 @@ +prefix . 'pr_dosing_rows'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), + ARRAY_A + ); + + return $row ? new PR_Core_Dosing_Row_DTO( $row ) : null; + } + + /** + * Find all active (non-superseded) dosing rows for a peptide. + * + * @param int $peptide_id Post ID of the peptide. + * @param array $filters Optional: route, population, evidence_strength. + * @return PR_Core_Dosing_Row_DTO[] + */ + public function find_by_peptide( int $peptide_id, array $filters = [] ): array { + global $wpdb; + $table = $wpdb->prefix . 'pr_dosing_rows'; + + $where = [ 'peptide_id = %d', 'superseded_by_id IS NULL' ]; + $params = [ $peptide_id ]; + + if ( ! empty( $filters['route'] ) ) { + $where[] = 'route = %s'; + $params[] = sanitize_text_field( $filters['route'] ); + } + + if ( ! empty( $filters['population'] ) ) { + $where[] = 'population = %s'; + $params[] = sanitize_text_field( $filters['population'] ); + } + + if ( ! empty( $filters['evidence_strength'] ) ) { + $where[] = 'evidence_strength = %s'; + $params[] = sanitize_text_field( $filters['evidence_strength'] ); + } + + $where_sql = implode( ' AND ', $where ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY evidence_strength DESC, study_year DESC", + ...$params + ), + ARRAY_A + ) ?: []; + + return array_map( + static fn( array $row ) => new PR_Core_Dosing_Row_DTO( $row ), + $rows + ); + } + + /** + * Insert a new dosing row. + * + * Fires pr_core_before_dosing_row_publish and pr_core_after_dosing_row_publish. + * + * Side effects: database insert. + * + * @param array $data Row data (peptide_id, dose_min, dose_max, etc.). + * @return int Inserted row ID, or 0 on failure. + */ + public function insert( array $data ): int { + global $wpdb; + $table = $wpdb->prefix . 'pr_dosing_rows'; + + $row = $this->sanitize_row( $data ); + $row['added_at'] = current_time( 'mysql' ); + $row['schema_version'] = 1; + + /** @see PR_Core::register_public_filters() — Documented lifecycle hook. */ + do_action( 'pr_core_before_dosing_row_publish', $row ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( $table, $row ); + + if ( false === $result ) { + return 0; + } + + $id = (int) $wpdb->insert_id; + + do_action( 'pr_core_after_dosing_row_publish', $id, $row ); + + return $id; + } + + /** + * Supersede an existing dosing row (soft-delete pattern). + * + * Creates a new row with corrections and marks the old row as superseded. + * + * Side effects: database insert + update. + * + * @param int $old_id ID of the row to supersede. + * @param array $new_data Corrected row data. + * @return int New row ID, or 0 on failure. + */ + public function supersede( int $old_id, array $new_data ): int { + $new_id = $this->insert( $new_data ); + + if ( 0 === $new_id ) { + return 0; + } + + global $wpdb; + $table = $wpdb->prefix . 'pr_dosing_rows'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->update( + $table, + [ 'superseded_by_id' => $new_id ], + [ 'id' => $old_id ], + [ '%d' ], + [ '%d' ] + ); + + return $new_id; + } + + /** + * Count active dosing rows for a peptide. + * + * @param int $peptide_id Post ID. + * @return int + */ + public function count_by_peptide( int $peptide_id ): int { + global $wpdb; + $table = $wpdb->prefix . 'pr_dosing_rows'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL", + $peptide_id + ) + ); + } + + /** + * Sanitize and validate a dosing row array. + * + * @param array $data Raw input. + * @return array Sanitized row. + */ + private function sanitize_row( array $data ): array { + return [ + 'peptide_id' => absint( $data['peptide_id'] ?? 0 ), + 'dose_min' => isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null, + 'dose_max' => isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null, + 'dose_unit' => sanitize_text_field( $data['dose_unit'] ?? '' ), + 'route' => sanitize_text_field( $data['route'] ?? '' ), + 'frequency' => isset( $data['frequency'] ) ? sanitize_text_field( $data['frequency'] ) : null, + 'duration_value' => isset( $data['duration_value'] ) ? absint( $data['duration_value'] ) : null, + 'duration_unit' => isset( $data['duration_unit'] ) ? sanitize_text_field( $data['duration_unit'] ) : null, + 'population' => sanitize_text_field( $data['population'] ?? '' ), + 'indication' => isset( $data['indication'] ) ? sanitize_text_field( $data['indication'] ) : null, + 'evidence_strength' => PR_Core_Peptide_CPT::sanitize_evidence_strength( $data['evidence_strength'] ?? '' ), + 'study_title' => isset( $data['study_title'] ) ? sanitize_text_field( $data['study_title'] ) : null, + 'study_year' => isset( $data['study_year'] ) ? absint( $data['study_year'] ) : null, + 'citation_pubmed_id' => isset( $data['citation_pubmed_id'] ) ? sanitize_text_field( $data['citation_pubmed_id'] ) : null, + 'citation_doi' => isset( $data['citation_doi'] ) ? sanitize_text_field( $data['citation_doi'] ) : null, + 'citation_url' => isset( $data['citation_url'] ) ? esc_url_raw( $data['citation_url'] ) : null, + 'notes' => isset( $data['notes'] ) ? sanitize_textarea_field( $data['notes'] ) : null, + 'source' => sanitize_text_field( $data['source'] ?? 'manual' ), + 'ai_candidate_id' => isset( $data['ai_candidate_id'] ) ? absint( $data['ai_candidate_id'] ) : null, + 'added_by' => absint( $data['added_by'] ?? get_current_user_id() ), + 'reviewed_by' => isset( $data['reviewed_by'] ) ? absint( $data['reviewed_by'] ) : null, + 'reviewed_at' => $data['reviewed_at'] ?? null, + ]; + } +} diff --git a/includes/repositories/class-pr-core-legal-repository.php b/includes/repositories/class-pr-core-legal-repository.php new file mode 100644 index 0000000..f6abbbf --- /dev/null +++ b/includes/repositories/class-pr-core-legal-repository.php @@ -0,0 +1,200 @@ +prefix . 'pr_legal_cells'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), + ARRAY_A + ); + + return $row ? new PR_Core_Legal_Cell_DTO( $row ) : null; + } + + /** + * Find all active legal cells for a peptide. + * + * @param int $peptide_id Post ID. + * @return PR_Core_Legal_Cell_DTO[] + */ + public function find_by_peptide( int $peptide_id ): array { + global $wpdb; + $table = $wpdb->prefix . 'pr_legal_cells'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL ORDER BY country_code ASC", + $peptide_id + ), + ARRAY_A + ) ?: []; + + return array_map( + static fn( array $row ) => new PR_Core_Legal_Cell_DTO( $row ), + $rows + ); + } + + /** + * Find the active legal cell for a specific peptide + country. + * + * @param int $peptide_id Post ID. + * @param string $country_code ISO 3166-1 alpha-2 code. + * @return PR_Core_Legal_Cell_DTO|null + */ + public function find_by_peptide_and_country( int $peptide_id, string $country_code ): ?PR_Core_Legal_Cell_DTO { + global $wpdb; + $table = $wpdb->prefix . 'pr_legal_cells'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE peptide_id = %d AND country_code = %s AND superseded_by_id IS NULL", + $peptide_id, + strtoupper( sanitize_text_field( $country_code ) ) + ), + ARRAY_A + ); + + return $row ? new PR_Core_Legal_Cell_DTO( $row ) : null; + } + + /** + * Find all active cells for a country (across all peptides). + * + * @param string $country_code ISO 3166-1 alpha-2 code. + * @return PR_Core_Legal_Cell_DTO[] + */ + public function find_by_country( string $country_code ): array { + global $wpdb; + $table = $wpdb->prefix . 'pr_legal_cells'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE country_code = %s AND superseded_by_id IS NULL ORDER BY peptide_id ASC", + strtoupper( sanitize_text_field( $country_code ) ) + ), + ARRAY_A + ) ?: []; + + return array_map( + static fn( array $row ) => new PR_Core_Legal_Cell_DTO( $row ), + $rows + ); + } + + /** + * Insert a new legal cell. + * + * Fires pr_core_before_legal_cell_publish and pr_core_after_legal_cell_publish. + * + * Side effects: database insert. + * + * @param array $data Cell data. + * @return int Inserted row ID, or 0 on failure. + */ + public function insert( array $data ): int { + global $wpdb; + $table = $wpdb->prefix . 'pr_legal_cells'; + + $row = $this->sanitize_row( $data ); + + do_action( 'pr_core_before_legal_cell_publish', $row ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( $table, $row ); + + if ( false === $result ) { + return 0; + } + + $id = (int) $wpdb->insert_id; + + do_action( 'pr_core_after_legal_cell_publish', $id, $row ); + + return $id; + } + + /** + * Supersede an existing legal cell (update with full history). + * + * @param int $old_id ID of the cell to supersede. + * @param array $new_data Updated cell data. + * @return int New cell ID, or 0 on failure. + */ + public function supersede( int $old_id, array $new_data ): int { + $new_id = $this->insert( $new_data ); + + if ( 0 === $new_id ) { + return 0; + } + + global $wpdb; + $table = $wpdb->prefix . 'pr_legal_cells'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->update( + $table, + [ 'superseded_by_id' => $new_id ], + [ 'id' => $old_id ], + [ '%d' ], + [ '%d' ] + ); + + return $new_id; + } + + /** + * Sanitize a legal cell row. + * + * @param array $data Raw input. + * @return array Sanitized row. + */ + private function sanitize_row( array $data ): array { + $valid_statuses = PR_Core_Migration_0002_Legal_Cells::STATUSES; + $status = sanitize_text_field( $data['status'] ?? 'unclear' ); + + if ( ! in_array( $status, $valid_statuses, true ) ) { + $status = 'unclear'; + } + + return [ + 'peptide_id' => absint( $data['peptide_id'] ?? 0 ), + 'country_code' => strtoupper( sanitize_text_field( $data['country_code'] ?? '' ) ), + 'status' => $status, + 'regulatory_framework' => isset( $data['regulatory_framework'] ) ? sanitize_text_field( $data['regulatory_framework'] ) : null, + 'regulatory_text_url' => isset( $data['regulatory_text_url'] ) ? esc_url_raw( $data['regulatory_text_url'] ) : null, + 'regulatory_text_quote' => isset( $data['regulatory_text_quote'] ) ? sanitize_textarea_field( $data['regulatory_text_quote'] ) : null, + 'notes' => isset( $data['notes'] ) ? sanitize_textarea_field( $data['notes'] ) : null, + 'last_verified_at' => sanitize_text_field( $data['last_verified_at'] ?? current_time( 'mysql' ) ), + 'schema_version' => 1, + 'reviewer_id' => absint( $data['reviewer_id'] ?? get_current_user_id() ), + ]; + } +} diff --git a/includes/repositories/class-pr-core-peptide-repository.php b/includes/repositories/class-pr-core-peptide-repository.php new file mode 100644 index 0000000..6a40da4 --- /dev/null +++ b/includes/repositories/class-pr-core-peptide-repository.php @@ -0,0 +1,162 @@ +post_type ) { + return null; + } + + return $this->post_to_dto( $post ); + } + + /** + * Find a peptide by slug. + * + * @param string $slug Post slug. + * @return PR_Core_Peptide_DTO|null + */ + public function find_by_slug( string $slug ): ?PR_Core_Peptide_DTO { + $posts = get_posts( [ + 'post_type' => PR_Core_Peptide_CPT::POST_TYPE, + 'name' => sanitize_title( $slug ), + 'posts_per_page' => 1, + 'post_status' => 'any', + ] ); + + return ! empty( $posts ) ? $this->post_to_dto( $posts[0] ) : null; + } + + /** + * Search peptides by name or alias. + * + * @param string $query Search term. + * @param int $limit Max results (default 20). + * @return PR_Core_Peptide_DTO[] + */ + public function search( string $query, int $limit = 20 ): array { + $query = sanitize_text_field( $query ); + + $posts = get_posts( [ + 'post_type' => PR_Core_Peptide_CPT::POST_TYPE, + 's' => $query, + 'posts_per_page' => $limit, + 'post_status' => 'publish', + ] ); + + return array_map( [ $this, 'post_to_dto' ], $posts ); + } + + /** + * Find all peptides matching filters. + * + * @param array $filters Supported: status, category, family, + * evidence_strength, per_page, page. + * @return PR_Core_Peptide_DTO[] + */ + public function find_all( array $filters = [] ): array { + $args = [ + 'post_type' => PR_Core_Peptide_CPT::POST_TYPE, + 'posts_per_page' => (int) ( $filters['per_page'] ?? 100 ), + 'paged' => (int) ( $filters['page'] ?? 1 ), + 'orderby' => 'title', + 'order' => 'ASC', + ]; + + $args['post_status'] = $filters['status'] ?? 'publish'; + + if ( ! empty( $filters['category'] ) ) { + $args['tax_query'] = [ [ + 'taxonomy' => PR_Core_Peptide_CPT::TAX_CATEGORY, + 'field' => 'slug', + 'terms' => sanitize_text_field( $filters['category'] ), + ] ]; + } + + if ( ! empty( $filters['family'] ) ) { + $args['tax_query'] = $args['tax_query'] ?? []; + $args['tax_query'][] = [ + 'taxonomy' => PR_Core_Peptide_CPT::TAX_FAMILY, + 'field' => 'slug', + 'terms' => sanitize_text_field( $filters['family'] ), + ]; + } + + if ( ! empty( $filters['evidence_strength'] ) ) { + $args['meta_query'] = [ [ + 'key' => 'evidence_strength', + 'value' => sanitize_text_field( $filters['evidence_strength'] ), + ] ]; + } + + $posts = get_posts( $args ); + + return array_map( [ $this, 'post_to_dto' ], $posts ); + } + + /** + * Count total peptides matching a status. + * + * @param string $status Post status (default 'publish'). + * @return int + */ + public function count( string $status = 'publish' ): int { + $counts = wp_count_posts( PR_Core_Peptide_CPT::POST_TYPE ); + return (int) ( $counts->$status ?? 0 ); + } + + /** + * Convert a WP_Post to a Peptide DTO. + * + * @param \WP_Post $post WordPress post object. + * @return PR_Core_Peptide_DTO + */ + private function post_to_dto( \WP_Post $post ): PR_Core_Peptide_DTO { + $aliases_raw = get_post_meta( $post->ID, 'aliases', true ); + $aliases = json_decode( $aliases_raw ?: '[]', true ) ?: []; + + $categories = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_CATEGORY, [ 'fields' => 'names' ] ); + $families = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_FAMILY, [ 'fields' => 'names' ] ); + + return new PR_Core_Peptide_DTO( [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'slug' => $post->post_name, + 'content' => $post->post_content, + 'excerpt' => $post->post_excerpt, + 'status' => $post->post_status, + 'display_name' => get_post_meta( $post->ID, 'display_name', true ) ?: '', + 'aliases' => $aliases, + 'molecular_formula' => get_post_meta( $post->ID, 'molecular_formula', true ) ?: '', + 'molecular_weight' => (float) get_post_meta( $post->ID, 'molecular_weight', true ), + 'cas_number' => get_post_meta( $post->ID, 'cas_number', true ) ?: '', + 'drugbank_id' => get_post_meta( $post->ID, 'drugbank_id', true ) ?: '', + 'chembl_id' => get_post_meta( $post->ID, 'chembl_id', true ) ?: '', + 'evidence_strength' => get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical', + 'editorial_review_status' => get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft', + 'last_editorial_review_at' => get_post_meta( $post->ID, 'last_editorial_review_at', true ) ?: '', + 'medical_editor_id' => (int) get_post_meta( $post->ID, 'medical_editor_id', true ), + 'categories' => is_array( $categories ) ? $categories : [], + 'families' => is_array( $families ) ? $families : [], + ] ); + } +} diff --git a/peptide-repo-core.php b/peptide-repo-core.php new file mode 100644 index 0000000..fc35fcf --- /dev/null +++ b/peptide-repo-core.php @@ -0,0 +1,46 @@ +init(); +} ); diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..439c33f --- /dev/null +++ b/uninstall.php @@ -0,0 +1,74 @@ +prefix . 'pr_dosing_rows', + $wpdb->prefix . 'pr_legal_cells', + $wpdb->prefix . 'pr_ai_candidate_queue', +]; + +foreach ( $tables as $table ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); +} + +/* ── 2. Delete all pr_peptide posts (cascade deletes meta) ────────────── */ + +$post_ids = $wpdb->get_col( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'pr_peptide'" +); + +foreach ( $post_ids as $post_id ) { + wp_delete_post( (int) $post_id, true ); +} + +/* ── 3. Delete taxonomy terms ─────────────────────────────────────────── */ + +$taxonomies = [ 'pr_peptide_category', 'pr_peptide_family' ]; + +foreach ( $taxonomies as $taxonomy ) { + $terms = get_terms( [ + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'ids', + ] ); + + if ( is_array( $terms ) ) { + foreach ( $terms as $term_id ) { + wp_delete_term( (int) $term_id, $taxonomy ); + } + } +} + +/* ── 4. Delete all pr_core_ prefixed options ──────────────────────────── */ + +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery +$wpdb->query( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pr\_core\_%'" +); + +/* ── 5. Remove manage_peptide_content capability from all roles ────────── */ + +$editable_roles = wp_roles()->roles; + +foreach ( array_keys( $editable_roles ) as $role_name ) { + $role = get_role( $role_name ); + if ( $role && $role->has_cap( 'manage_peptide_content' ) ) { + $role->remove_cap( 'manage_peptide_content' ); + } +}