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

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 : [],
] );
}
}