Squash-merge of PR #22: adopt shared reusable CI workflow, convert PHPUnit to stubs, update branch-protection contexts.
QA-approved at 05d8094a1d. All required CI checks green.
Agent-Session: 2026-06-17-prcore-ci-deploy
217 lines
7.3 KiB
PHP
217 lines
7.3 KiB
PHP
<?php
|
|
/**
|
|
* Dosing Repository.
|
|
*
|
|
* @package Peptide_Repo_Core
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* CRUD operations for dosing data, returning typed DTOs
|
|
*
|
|
* @package Peptide_Repo_Core
|
|
*/
|
|
|
|
/**
|
|
* 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 ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
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() ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'pr_dosing_rows';
|
|
|
|
$where = array( 'peptide_id = %d', 'superseded_by_id IS NULL' );
|
|
$params = array( $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( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- dynamic {$where_sql} is builder-constructed, not a placeholder
|
|
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY evidence_strength DESC, study_year DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
...$params
|
|
),
|
|
ARRAY_A
|
|
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
|
|
|
|
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;
|
|
|
|
/**
|
|
* Property.
|
|
*
|
|
* @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,
|
|
array( 'superseded_by_id' => $new_id ),
|
|
array( 'id' => $old_id ),
|
|
array( '%d' ),
|
|
array( '%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", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$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 array(
|
|
'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,
|
|
);
|
|
}
|
|
}
|