feat: initial peptide-repo-core plugin (v0.1.0) — canonical schema sprint

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 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-17 07:36:12 +07:00
commit 808b2509d8
33 changed files with 3728 additions and 0 deletions

74
.github/workflows/ci.yml vendored Normal file
View File

@@ -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."

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
vendor/
node_modules/
.env
.env.*
*.log
.DS_Store
Thumbs.db

180
ARCHITECTURE.md Normal file
View File

@@ -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.

27
CHANGELOG.md Normal file
View File

@@ -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.

63
CONVENTIONS.md Normal file
View File

@@ -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.

22
assets/css/admin.css Normal file
View File

@@ -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;
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* Custom admin list-table columns for the pr_peptide CPT.
*
* What: Adds evidence strength, editorial status, and dosing count columns.
* Who calls it: PR_Core_Admin via manage_posts_columns and custom_column hooks.
* Dependencies: PR_Core_Dosing_Repository for row counts.
*
* @see admin/class-pr-core-admin.php — Registers column hooks.
*/
class PR_Core_Admin_Columns {
/**
* Add custom columns to the peptide list table.
*
* @param array<string, string> $columns Existing columns.
* @return array<string, string> 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;
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* Admin initialization and hook registration.
*
* What: Registers admin menu pages, meta boxes, and custom columns for peptides.
* Who calls it: PR_Core::init() when is_admin().
* Dependencies: PR_Core_Peptide_Metaboxes, PR_Core_Candidate_Queue_Page, PR_Core_Admin_Columns.
*
* @see admin/class-pr-core-peptide-metaboxes.php — Dosing + legal meta boxes.
* @see admin/class-pr-core-candidate-queue-page.php — Candidate review screen.
* @see admin/class-pr-core-admin-columns.php — Custom list-table columns.
*/
class PR_Core_Admin {
/**
* Register all admin hooks.
*
* Side effects: registers add_meta_boxes, save_post, admin_menu hooks.
*
* @return void
*/
public function register_hooks(): void {
$metaboxes = new PR_Core_Peptide_Metaboxes();
add_action( 'add_meta_boxes', [ $metaboxes, 'add_meta_boxes' ] );
add_action( 'save_post_' . PR_Core_Peptide_CPT::POST_TYPE, [ $metaboxes, 'save_meta' ], 10, 2 );
$columns = new PR_Core_Admin_Columns();
add_filter( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_columns', [ $columns, 'add_columns' ] );
add_action( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_custom_column', [ $columns, 'render_column' ], 10, 2 );
add_action( 'admin_menu', [ $this, 'add_admin_pages' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_styles' ] );
}
/**
* Add the Candidate Queue submenu page under the Peptides menu.
*
* Side effects: registers admin menu page.
*
* @return void
*/
public function add_admin_pages(): void {
$pending_count = ( new PR_Core_Candidate_Queue_Repository() )->count_by_status( 'pending' );
$badge = $pending_count > 0 ? sprintf( ' <span class="awaiting-mod">%d</span>', $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
);
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/**
* Admin page for the AI Candidate Queue.
*
* What: Renders a review screen for AI-extracted dosing candidates.
* Who calls it: PR_Core_Admin::add_admin_pages() registers this as a submenu page.
* Dependencies: PR_Core_Candidate_Queue_Repository, PR_Core_Peptide_Repository.
*
* Researchers see pending candidates sorted by confidence, can approve/reject with notes.
*
* @see admin/class-pr-core-admin.php — Menu registration.
* @see repositories/class-pr-core-candidate-queue-repository.php — Data layer.
*/
class PR_Core_Candidate_Queue_Page {
/**
* Render the candidate queue admin page.
*
* Side effects: processes approve/reject actions, outputs HTML.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( PR_Core_Peptide_CPT::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'peptide-repo-core' ) );
}
$this->handle_actions();
$repo = new PR_Core_Candidate_Queue_Repository();
$candidates = $repo->find_by_status( 'pending' );
$pep_repo = new PR_Core_Peptide_Repository();
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'AI Candidate Queue', 'peptide-repo-core' ) . '</h1>';
echo '<p>' . esc_html__( 'Review AI-extracted dosing data candidates. Approved rows are copied to the dosing table.', 'peptide-repo-core' ) . '</p>';
if ( empty( $candidates ) ) {
echo '<p><strong>' . esc_html__( 'No pending candidates.', 'peptide-repo-core' ) . '</strong></p>';
echo '</div>';
return;
}
echo '<table class="widefat striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Peptide', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Route', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Dose Range', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Population', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Study', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Confidence', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Actions', 'peptide-repo-core' ) . '</th>';
echo '</tr></thead><tbody>';
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( ' <a href="https://pubmed.ncbi.nlm.nih.gov/%s/" target="_blank">PubMed</a>', 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 '<tr>';
printf( '<td>%s</td>', esc_html( $name ) );
printf( '<td>%s</td>', esc_html( $c->route ) );
printf( '<td>%s</td>', esc_html( $dose ) );
printf( '<td>%s</td>', esc_html( $c->population ) );
printf( '<td>%s</td>', $study ); // Contains escaped HTML + link.
printf( '<td>%s</td>', esc_html( $confidence_pct ) );
printf(
'<td><a href="%s" class="button button-primary button-small">%s</a> <a href="%s" class="button button-small">%s</a></td>',
esc_url( $approve_url ),
esc_html__( 'Approve', 'peptide-repo-core' ),
esc_url( $reject_url ),
esc_html__( 'Reject', 'peptide-repo-core' )
);
echo '</tr>';
}
echo '</tbody></table></div>';
}
/**
* 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 '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Candidate approved and added to dosing data.', 'peptide-repo-core' ) . '</p></div>';
} );
}
} else {
$repo->reject( $candidate_id, $reviewer_id );
add_action( 'admin_notices', static function () {
echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html__( 'Candidate rejected.', 'peptide-repo-core' ) . '</p></div>';
} );
}
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
/**
* Meta boxes for the peptide edit screen: scientific identifiers, dosing, and legal.
*
* What: Renders and saves three meta boxes on the pr_peptide edit screen.
* Who calls it: PR_Core_Admin via add_meta_boxes and save_post hooks.
* Dependencies: PR_Core_Dosing_Repository, PR_Core_Legal_Repository.
*
* @see admin/class-pr-core-admin.php — Registers these hooks.
* @see cpt/class-pr-core-peptide-cpt.php — Meta field definitions.
*/
class PR_Core_Peptide_Metaboxes {
/**
* Register meta boxes on the peptide edit screen.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'pr-core-identifiers',
__( 'Scientific Identifiers', 'peptide-repo-core' ),
[ $this, 'render_identifiers_box' ],
PR_Core_Peptide_CPT::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'pr-core-dosing',
__( 'Dosing Data', 'peptide-repo-core' ),
[ $this, 'render_dosing_box' ],
PR_Core_Peptide_CPT::POST_TYPE,
'normal',
'default'
);
add_meta_box(
'pr-core-legal',
__( 'Legal Status by Country', 'peptide-repo-core' ),
[ $this, 'render_legal_box' ],
PR_Core_Peptide_CPT::POST_TYPE,
'normal',
'default'
);
}
/**
* Render the Scientific Identifiers meta box.
*
* @param \WP_Post $post Current post.
* @return void
*/
public function render_identifiers_box( \WP_Post $post ): void {
wp_nonce_field( 'pr_core_save_meta', 'pr_core_meta_nonce' );
$fields = [
'display_name' => __( '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 '<table class="form-table pr-core-meta-table">';
foreach ( $fields as $key => $label ) {
$value = esc_attr( (string) get_post_meta( $post->ID, $key, true ) );
printf(
'<tr><th><label for="pr_core_%1$s">%2$s</label></th><td><input type="text" id="pr_core_%1$s" name="pr_core_%1$s" value="%3$s" class="regular-text" /></td></tr>',
esc_attr( $key ),
esc_html( $label ),
$value
);
}
// Evidence strength dropdown.
$current_strength = get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical';
echo '<tr><th><label for="pr_core_evidence_strength">' . esc_html__( 'Evidence Strength', 'peptide-repo-core' ) . '</label></th><td><select id="pr_core_evidence_strength" name="pr_core_evidence_strength">';
foreach ( PR_Core_Peptide_CPT::EVIDENCE_STRENGTHS as $strength ) {
$label = apply_filters( 'pr_core_evidence_strength_label', $strength, $strength );
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $strength ),
selected( $current_strength, $strength, false ),
esc_html( $label )
);
}
echo '</select></td></tr>';
// Editorial review status.
$current_review = get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft';
echo '<tr><th><label for="pr_core_editorial_review_status">' . esc_html__( 'Editorial Status', 'peptide-repo-core' ) . '</label></th><td><select id="pr_core_editorial_review_status" name="pr_core_editorial_review_status">';
foreach ( PR_Core_Peptide_CPT::REVIEW_STATUSES as $status ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $status ),
selected( $current_review, $status, false ),
esc_html( ucfirst( str_replace( '-', ' ', $status ) ) )
);
}
echo '</select></td></tr>';
echo '</table>';
}
/**
* 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 '<p>' . esc_html__( 'Save the peptide first, then add dosing data.', 'peptide-repo-core' ) . '</p>';
return;
}
$repo = new PR_Core_Dosing_Repository();
$rows = $repo->find_by_peptide( $post->ID );
if ( empty( $rows ) ) {
echo '<p>' . esc_html__( 'No dosing rows yet.', 'peptide-repo-core' ) . '</p>';
} else {
echo '<table class="widefat striped"><thead><tr>';
echo '<th>' . esc_html__( 'Route', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Dose Range', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Population', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Evidence', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Source', 'peptide-repo-core' ) . '</th>';
echo '</tr></thead><tbody>';
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(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
esc_html( $row->route ),
esc_html( $dose ),
esc_html( $row->population ),
esc_html( $evidence_label ),
esc_html( $row->source )
);
}
echo '</tbody></table>';
}
printf(
'<p class="description">%s</p>',
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 '<p>' . esc_html__( 'Save the peptide first, then add legal data.', 'peptide-repo-core' ) . '</p>';
return;
}
$repo = new PR_Core_Legal_Repository();
$cells = $repo->find_by_peptide( $post->ID );
if ( empty( $cells ) ) {
echo '<p>' . esc_html__( 'No legal status data yet.', 'peptide-repo-core' ) . '</p>';
} else {
echo '<table class="widefat striped"><thead><tr>';
echo '<th>' . esc_html__( 'Country', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Status', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Framework', 'peptide-repo-core' ) . '</th>';
echo '<th>' . esc_html__( 'Verified', 'peptide-repo-core' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $cells as $cell ) {
printf(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
esc_html( $cell->country_code ),
esc_html( ucfirst( $cell->status ) ),
esc_html( $cell->regulatory_framework ?? '—' ),
esc_html( $cell->last_verified_at )
);
}
echo '</tbody></table>';
}
printf(
'<p class="description">%s</p>',
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 );
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
/**
* REST API controller for peptide data (read-only public, write requires capability).
*
* What: Exposes peptides, dosing rows, and legal cells via REST endpoints.
* Who calls it: PR_Core::init() registers rest_api_init hook.
* Dependencies: PR_Core_Peptide_Repository, PR_Core_Dosing_Repository,
* PR_Core_Legal_Repository.
*
* Namespace: pr-core/v1
* Endpoints:
* GET /peptides — List peptides (public)
* GET /peptides/{id} — Single peptide (public)
* GET /peptides/{id}/dosing — Dosing rows for peptide (public)
* GET /peptides/{id}/legal — Legal cells for peptide (public)
* POST /peptides/{id}/dosing — Add dosing row (auth)
* POST /peptides/{id}/legal — Add legal cell (auth)
*
* @see ARCHITECTURE.md — REST API specification.
*/
class PR_Core_Rest_Controller {
/** @var string REST namespace. */
private const NAMESPACE = 'pr-core/v1';
/**
* Register the rest_api_init hook.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Register all REST routes.
*
* Side effects: registers routes with WordPress REST API.
*
* @return void
*/
public function register_routes(): void {
// GET /peptides
register_rest_route( self::NAMESPACE, '/peptides', [
'methods' => '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<id>\d+)', [
'methods' => 'GET',
'callback' => [ $this, 'get_peptide' ],
'permission_callback' => '__return_true',
] );
// GET /peptides/{id}/dosing
register_rest_route( self::NAMESPACE, '/peptides/(?P<id>\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<id>\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<id>\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<id>\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 );
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* Plugin activation handler.
*
* What: Runs migrations, adds capabilities, flushes rewrite rules.
* Who calls it: register_activation_hook() in peptide-repo-core.php.
* Dependencies: PR_Core_Migration_Runner.
*
* @see peptide-repo-core.php — Registers this class on activation.
*/
class PR_Core_Activator {
/**
* Run on plugin activation.
*
* Side effects: database table creation, capability grants, rewrite flush.
*
* @return void
*/
public static function activate(): void {
// Run all pending migrations (creates tables).
$runner = new PR_Core_Migration_Runner();
$runner->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 );
}
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* SPL autoloader for PR_Core_ prefixed classes.
*
* What: Resolves PR_Core_ class names to file paths under includes/.
* Who calls it: peptide-repo-core.php bootstrap.
* Dependencies: None.
*
* Naming convention: PR_Core_Foo_Bar => 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;
}
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* Plugin deactivation handler.
*
* What: Flushes rewrite rules on deactivation (data preserved).
* Who calls it: register_deactivation_hook() in peptide-repo-core.php.
* Dependencies: None.
*
* @see peptide-repo-core.php — Registers this class on deactivation.
* @see uninstall.php — Full data teardown happens on uninstall, not deactivation.
*/
class PR_Core_Deactivator {
/**
* Run on plugin deactivation.
*
* Side effects: flushes rewrite rules.
*
* @return void
*/
public static function deactivate(): void {
flush_rewrite_rules();
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
/**
* Seed data fixture for development and testing.
*
* What: Creates 3 peptide posts, 10 dosing rows, and 5 legal cells.
* Who calls it: WP-CLI command or admin action (manual trigger only).
* Dependencies: PR_Core_Peptide_CPT, PR_Core_Dosing_Repository, PR_Core_Legal_Repository.
*
* @see ARCHITECTURE.md — Seed data specification.
*/
class PR_Core_Seed_Data {
/**
* Run the seed to populate the database with sample data.
*
* Side effects: creates posts, inserts dosing rows and legal cells.
*
* @return array{peptides: int, dosing_rows: int, legal_cells: int} Counts of created records.
*/
public static function run(): array {
$peptide_ids = self::seed_peptides();
$dosing = self::seed_dosing_rows( $peptide_ids );
$legal = self::seed_legal_cells( $peptide_ids );
return [
'peptides' => count( $peptide_ids ),
'dosing_rows' => $dosing,
'legal_cells' => $legal,
];
}
/**
* Create 3 canonical peptide posts.
*
* @return array<string, int> 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<string, int> $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<string, int> $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;
}
}

125
includes/class-pr-core.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* Main orchestrator for the Peptide Repo Core plugin.
*
* What: Registers all hooks, runs migrations, boots subsystems.
* Who calls it: peptide-repo-core.php on plugins_loaded.
* Dependencies: PR_Core_Migration_Runner, PR_Core_Peptide_CPT, PR_Core_Admin,
* PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Rest_Controller.
*
* @see peptide-repo-core.php — Bootstrap that instantiates this class.
* @see ARCHITECTURE.md — Full data flow diagram.
*/
class PR_Core {
/**
* Initialize all plugin subsystems.
*
* Side effects: registers WordPress hooks, runs pending migrations.
*
* @return void
*/
public function init(): void {
// Run migrations before anything else.
$runner = new PR_Core_Migration_Runner();
$runner->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<int, array<string, mixed>> $entries Existing entries from other filters.
* @return array<int, array<string, mixed>>
*/
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;
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
/**
* Registers the pr_peptide custom post type and associated taxonomies.
*
* What: Defines the peptide CPT with REST support, archive, and monograph meta fields.
* Who calls it: PR_Core::init() on plugins_loaded.
* Dependencies: None.
*
* @see ARCHITECTURE.md — CPT specification and post-meta field definitions.
* @see cpt/class-pr-core-peptide-taxonomies.php — Not used; taxonomies registered here for simplicity.
*/
class PR_Core_Peptide_CPT {
/** @var string Post type slug. */
public const POST_TYPE = 'pr_peptide';
/** @var string Taxonomy: category (e.g., GLP-1 agonist). */
public const TAX_CATEGORY = 'pr_peptide_category';
/** @var string Taxonomy: family grouping. */
public const TAX_FAMILY = 'pr_peptide_family';
/** @var string Capability required for editing peptide data. */
public const CAPABILITY = 'manage_peptide_content';
/**
* Evidence strength enum values, ordered weakest to strongest.
*
* @var string[]
*/
public const EVIDENCE_STRENGTHS = [
'preclinical',
'case-series',
'observational',
'rct-small',
'rct-large',
'meta-analysis',
];
/**
* Editorial review status enum values.
*
* @var string[]
*/
public const REVIEW_STATUSES = [
'draft',
'in-review',
'published',
'retired',
];
/**
* Post-meta field definitions (1:1 with peptide).
* Key => [ 'type', 'default', 'sanitize_callback', 'show_in_rest' ].
*
* @return array<string, array<string, mixed>>
*/
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';
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/**
* Typed data-transfer object for an AI candidate queue entry.
*
* What: Immutable value object wrapping a row from pr_ai_candidate_queue.
* Who calls it: PR_Core_Candidate_Queue_Repository returns these.
* Dependencies: None.
*
* @see migrations/class-pr-core-migration-0003-candidate-queue.php — Table schema.
* @see repositories/class-pr-core-candidate-queue-repository.php — Creates these.
*/
class PR_Core_Candidate_DTO {
public readonly int $id;
public readonly int $peptide_id;
public readonly ?float $dose_min;
public readonly ?float $dose_max;
public readonly string $dose_unit;
public readonly string $route;
public readonly ?string $frequency;
public readonly ?int $duration_value;
public readonly ?string $duration_unit;
public readonly string $population;
public readonly ?string $indication;
public readonly string $evidence_strength;
public readonly ?string $study_title;
public readonly ?int $study_year;
public readonly ?string $citation_pubmed_id;
public readonly ?string $citation_doi;
public readonly ?string $citation_url;
public readonly ?string $notes;
public readonly float $extraction_confidence;
public readonly string $queue_status;
public readonly string $extracted_at;
public readonly ?int $reviewed_by;
public readonly ?string $reviewed_at;
public readonly ?string $reviewer_notes;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* Typed data-transfer object for a dosing row.
*
* What: Immutable value object wrapping a row from pr_dosing_rows.
* Who calls it: PR_Core_Dosing_Repository returns these; consumers read them.
* Dependencies: None.
*
* @see migrations/class-pr-core-migration-0001-dosing-rows.php — Table schema.
* @see repositories/class-pr-core-dosing-repository.php — Creates these from DB rows.
*/
class PR_Core_Dosing_Row_DTO {
public readonly int $id;
public readonly int $peptide_id;
public readonly ?float $dose_min;
public readonly ?float $dose_max;
public readonly string $dose_unit;
public readonly string $route;
public readonly ?string $frequency;
public readonly ?int $duration_value;
public readonly ?string $duration_unit;
public readonly string $population;
public readonly ?string $indication;
public readonly string $evidence_strength;
public readonly ?string $study_title;
public readonly ?int $study_year;
public readonly ?string $citation_pubmed_id;
public readonly ?string $citation_doi;
public readonly ?string $citation_url;
public readonly ?string $notes;
public readonly int $schema_version;
public readonly string $source;
public readonly ?int $ai_candidate_id;
public readonly int $added_by;
public readonly string $added_at;
public readonly ?int $reviewed_by;
public readonly ?string $reviewed_at;
public readonly ?int $superseded_by_id;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* Typed data-transfer object for a legal status cell.
*
* What: Immutable value object wrapping a row from pr_legal_cells.
* Who calls it: PR_Core_Legal_Repository returns these; consumers read them.
* Dependencies: None.
*
* @see migrations/class-pr-core-migration-0002-legal-cells.php — Table schema.
* @see repositories/class-pr-core-legal-repository.php — Creates these from DB rows.
*/
class PR_Core_Legal_Cell_DTO {
public readonly int $id;
public readonly int $peptide_id;
public readonly string $country_code;
public readonly string $status;
public readonly ?string $regulatory_framework;
public readonly ?string $regulatory_text_url;
public readonly ?string $regulatory_text_quote;
public readonly ?string $notes;
public readonly string $last_verified_at;
public readonly int $schema_version;
public readonly int $reviewer_id;
public readonly ?int $superseded_by_id;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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(),
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* Typed data-transfer object for a peptide record.
*
* What: Immutable value object wrapping a pr_peptide post + its meta fields.
* Who calls it: PR_Core_Peptide_Repository returns these; consumers read them.
* Dependencies: None.
*
* @see cpt/class-pr-core-peptide-cpt.php — Meta field definitions.
* @see repositories/class-pr-core-peptide-repository.php — Creates these from WP_Post.
*/
class PR_Core_Peptide_DTO {
public readonly int $id;
public readonly string $title;
public readonly string $slug;
public readonly string $content;
public readonly string $excerpt;
public readonly string $status;
public readonly string $display_name;
/** @var string[] */
public readonly array $aliases;
public readonly string $molecular_formula;
public readonly float $molecular_weight;
public readonly string $cas_number;
public readonly string $drugbank_id;
public readonly string $chembl_id;
public readonly string $evidence_strength;
public readonly string $editorial_review_status;
public readonly string $last_editorial_review_at;
public readonly int $medical_editor_id;
/** @var string[] Category term names. */
public readonly array $categories;
/** @var string[] Family term names. */
public readonly array $families;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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 ),
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
/**
* Shared disclaimer component for all peptide data surfaces.
*
* What: Provides shortcode [pr_disclaimer surface="dosing"] and function
* pr_core_render_disclaimer() for rendering surface-specific disclaimers.
* Who calls it: PR_Core::init() registers shortcode; consumer plugins call static methods.
* Dependencies: None (reads from wp_options).
*
* Disclaimer copy is stored in pr_core_disclaimer_copy option (JSON keyed by surface),
* versioned by pr_core_disclaimer_version. Editorial review happens once here,
* not once per consumer plugin.
*
* @see ARCHITECTURE.md — Section 2.8 Shared Disclaimer Component.
*/
class PR_Core_Disclaimer {
/** @var string Option key for disclaimer copy (JSON). */
private const COPY_OPTION = 'pr_core_disclaimer_copy';
/** @var string Option key for disclaimer version. */
private const VERSION_OPTION = 'pr_core_disclaimer_version';
/**
* Default disclaimer text per surface.
*
* @var array<string, string>
*/
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, string>|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(
'<div class="pr-core-disclaimer" data-surface="%s" role="note" aria-label="%s"><p>%s</p></div>',
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 );
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* JSON-LD / schema.org structured data emission for peptide pages.
*
* What: Emits Drug schema on single peptide pages, Dataset schema on archives.
* Who calls it: PR_Core::init() registers wp_head hook.
* Dependencies: PR_Core_Peptide_Repository for data lookup.
*
* Consumer plugins can override via pr_core_jsonld_peptide filter.
*
* @see ARCHITECTURE.md — Section 2.7 JSON-LD output.
*/
class PR_Core_Jsonld {
/**
* Register hooks for JSON-LD output.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'wp_head', [ $this, 'emit_jsonld' ], 99 );
}
/**
* Emit JSON-LD on peptide single pages.
*
* Side effects: outputs script tag in wp_head.
*
* @return void
*/
public function emit_jsonld(): void {
if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
return;
}
$post_id = get_the_ID();
if ( ! $post_id ) {
return;
}
$repo = new PR_Core_Peptide_Repository();
$peptide = $repo->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<string, mixed> $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(
'<script type="application/ld+json">%s</script>' . "\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<string, mixed> 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;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* Migration 0001: Create the pr_dosing_rows table.
*
* What: Creates the high-cardinality dosing data table (1:many with peptide).
* Who calls it: PR_Core_Migration_Runner::run_pending().
* Dependencies: WordPress $wpdb, dbDelta().
*
* @see ARCHITECTURE.md — Table schema specification.
*/
class PR_Core_Migration_0001_Dosing_Rows {
/**
* Create the pr_dosing_rows table.
*
* Side effects: database DDL via dbDelta().
*
* @return void
*/
public function up(): void {
global $wpdb;
$table = $wpdb->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 );
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Migration 0002: Create the pr_legal_cells table.
*
* What: Creates the per-country legal status table (1:many with peptide).
* Who calls it: PR_Core_Migration_Runner::run_pending().
* Dependencies: WordPress $wpdb, dbDelta().
*
* Uniqueness: only one active cell per peptide x country (superseded_by_id NULL).
*
* @see ARCHITECTURE.md — Table schema specification.
*/
class PR_Core_Migration_0002_Legal_Cells {
/** @var string[] Valid legal status enum values. */
public const STATUSES = [
'prescription',
'ruo',
'otc',
'restricted',
'banned',
'unclear',
];
/**
* Create the pr_legal_cells table.
*
* Side effects: database DDL via dbDelta().
*
* @return void
*/
public function up(): void {
global $wpdb;
$table = $wpdb->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)"
);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* Migration 0003: Create the pr_ai_candidate_queue table.
*
* What: Creates the AI-extracted dosing candidate queue table.
* Who calls it: PR_Core_Migration_Runner::run_pending().
* Dependencies: WordPress $wpdb, dbDelta().
*
* Approved rows are copied into pr_dosing_rows with source='ai-candidate-approved'
* and ai_candidate_id set; queue row marked 'merged'.
*
* @see ARCHITECTURE.md — AI-assist candidate-extraction pipeline.
*/
class PR_Core_Migration_0003_Candidate_Queue {
/** @var string[] Valid queue status enum values. */
public const STATUSES = [
'pending',
'approved',
'rejected',
'merged',
];
/**
* Create the pr_ai_candidate_queue table.
*
* Side effects: database DDL via dbDelta().
*
* @return void
*/
public function up(): void {
global $wpdb;
$table = $wpdb->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 );
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/**
* Schema migration runner.
*
* What: Compares current schema version against target, runs pending migrations.
* Who calls it: PR_Core::init() on every plugins_loaded, PR_Core_Activator on activation.
* Dependencies: WordPress $wpdb, dbDelta().
*
* Migrations are numbered sequentially (0001, 0002, ...). Each migration class
* must implement up() and optionally down(). All migrations are idempotent.
*
* @see peptide-repo-core.php — Defines PR_CORE_TARGET_SCHEMA_VERSION.
* @see CONVENTIONS.md — "How To: Add a New Migration".
*/
class PR_Core_Migration_Runner {
/** @var string Option key storing current schema version. */
private const VERSION_OPTION = 'pr_core_schema_version';
/**
* Ordered list of migration classes.
* Index 0 = migration 1 (bumps schema to version 1).
*
* @var string[]
*/
private const MIGRATIONS = [
'PR_Core_Migration_0001_Dosing_Rows',
'PR_Core_Migration_0002_Legal_Cells',
'PR_Core_Migration_0003_Candidate_Queue',
];
/**
* Run all pending migrations up to PR_CORE_TARGET_SCHEMA_VERSION.
*
* Side effects: creates/alters database tables, updates schema version option.
*
* @return void
*/
public function run_pending(): void {
$current = (int) get_option( self::VERSION_OPTION, 0 );
$target = PR_CORE_TARGET_SCHEMA_VERSION;
if ( $current >= $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;
}
}

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
/**
* Repository for AI candidate queue (pr_ai_candidate_queue table).
*
* What: CRUD for AI-extracted dosing candidates awaiting human review.
* Who calls it: Admin candidate queue page, extraction pipeline (stub).
* Dependencies: WordPress $wpdb, PR_Core_Candidate_DTO, PR_Core_Dosing_Repository.
*
* Flow: Extract (pending) -> 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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
/**
* Repository for dosing rows (pr_dosing_rows table).
*
* What: CRUD operations for dosing data, returning typed DTOs.
* Who calls it: REST controller, admin meta boxes, consumer plugins (Dosage Reference tool).
* Dependencies: WordPress $wpdb, PR_Core_Dosing_Row_DTO.
*
* @see migrations/class-pr-core-migration-0001-dosing-rows.php — Table schema.
* @see dto/class-pr-core-dosing-row-dto.php — Return type.
*/
class PR_Core_Dosing_Repository {
/**
* Find a single dosing row by ID.
*
* @param int $id Row ID.
* @return PR_Core_Dosing_Row_DTO|null
*/
public function find_by_id( int $id ): ?PR_Core_Dosing_Row_DTO {
global $wpdb;
$table = $wpdb->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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $data Raw input.
* @return array<string, mixed> 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,
];
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
/**
* Repository for legal status cells (pr_legal_cells table).
*
* What: CRUD operations for per-country legal status data, returning typed DTOs.
* Who calls it: REST controller, admin meta boxes, Legal Status Tracker tool.
* Dependencies: WordPress $wpdb, PR_Core_Legal_Cell_DTO.
*
* Returns only active (non-superseded) cells by default. Superseded cells
* are retained for audit history.
*
* @see migrations/class-pr-core-migration-0002-legal-cells.php — Table schema.
* @see dto/class-pr-core-legal-cell-dto.php — Return type.
*/
class PR_Core_Legal_Repository {
/**
* Find a single legal cell by ID.
*
* @param int $id Row ID.
* @return PR_Core_Legal_Cell_DTO|null
*/
public function find_by_id( int $id ): ?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 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<string, mixed> $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<string, mixed> $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<string, mixed> $data Raw input.
* @return array<string, mixed> 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() ),
];
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
/**
* Repository for peptide CPT records.
*
* What: CRUD operations over pr_peptide posts, returning typed DTOs.
* Who calls it: PR_Core (public API), REST controller, admin UI, consumer plugins.
* Dependencies: WordPress WP_Query, PR_Core_Peptide_DTO.
*
* @see dto/class-pr-core-peptide-dto.php — Return type.
* @see cpt/class-pr-core-peptide-cpt.php — CPT and meta registration.
* @see repositories/class-pr-core-dosing-repository.php — Related dosing data.
*/
class PR_Core_Peptide_Repository {
/**
* Find a single peptide by post ID.
*
* @param int $id WordPress post ID.
* @return PR_Core_Peptide_DTO|null Null if not found or wrong post type.
*/
public function find_by_id( int $id ): ?PR_Core_Peptide_DTO {
$post = get_post( $id );
if ( ! $post || PR_Core_Peptide_CPT::POST_TYPE !== $post->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<string, mixed> $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 : [],
] );
}
}

46
peptide-repo-core.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
/**
* Plugin Name: Peptide Repo Core
* Plugin URI: https://peptiderepo.com
* Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the pr_peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output.
* Version: 0.1.0
* Author: peptiderepo
* Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later
* Text Domain: peptide-repo-core
* Requires PHP: 8.0
*
* @see ARCHITECTURE.md — Full data flow and file tree.
* @see CONVENTIONS.md — Naming patterns and extension guides.
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/* ── Constants ────────────────────────────────────────────────────────── */
define( 'PR_CORE_VERSION', '0.1.0' );
define( 'PR_CORE_PLUGIN_FILE', __FILE__ );
define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PR_CORE_TARGET_SCHEMA_VERSION', 3 );
/* ── Autoloader ───────────────────────────────────────────────────────── */
require_once PR_CORE_PLUGIN_DIR . 'includes/class-pr-core-autoloader.php';
PR_Core_Autoloader::register();
/* ── Activation / Deactivation ────────────────────────────────────────── */
register_activation_hook( __FILE__, [ 'PR_Core_Activator', 'activate' ] );
register_deactivation_hook( __FILE__, [ 'PR_Core_Deactivator', 'deactivate' ] );
/* ── Boot ──────────────────────────────────────────────────────────────── */
add_action( 'plugins_loaded', static function (): void {
$plugin = new PR_Core();
$plugin->init();
} );

74
uninstall.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
/**
* Peptide Repo Core — Full data teardown on uninstall.
*
* Removes ALL plugin data: custom tables, CPT posts + meta, taxonomy terms,
* options, and capabilities. No orphaned data left behind.
*
* @see ARCHITECTURE.md — Section 2.9 Uninstall specification.
*/
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
/* ── 1. Drop custom tables ────────────────────────────────────────────── */
$tables = [
$wpdb->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' );
}
}