wavelog/application/libraries/api_v2/Qso_resource.php
2026-08-04 07:44:33 +00:00

897 lines
32 KiB
PHP

<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
require_once __DIR__ . '/Api_v2_resource.php';
/**
* API v2 - QSOs resource (reference implementation)
*
* Demonstrates the full CRUD pattern every other v2 resource should follow.
* It reuses the existing Logbook_model / Stations data layer rather than
* reimplementing QSO logic:
* - reads via Logbook_model::get_qso() (trusted, after our own ownership check)
* - lists via Logbook_model::get_qsos()
* - creates via Logbook_model::import_bulk() (the same path the v1 API uses)
* - updates via Logbook_model::update_qso_columns() (PATCH)
* - deletes via Logbook_model::delete() (handles OQRS/QSL/eQSL + cache)
*
* PATCH updates only the fields present in the body; anything omitted keeps its
* stored value. There is deliberately no PUT: Wavelog is the source of truth, so
* an API client should never be able to blank fields it simply did not know
* about - a client wanting a full overwrite can send every field explicitly.
* PATCH touches only the field whitelist in editable_fields(): QSL/LoTW/eQSL
* bookkeeping, DXCC/country recalculation and the MY_* station refs are
* deliberately out of scope here.
*
* Unit note: the JSON API speaks Hz throughout - POST (single and bulk), PATCH
* and GET all take and return freq/freq_rx in Hz. Unit suffixes are accepted on
* input (e.g. "7.0475M" = 7047500 Hz, see parse_frequency()). Only an ADIF
* payload (import_type=adif) is read as MHz, because that is what the ADIF
* standard prescribes; body_to_record() converts JSON Hz to MHz internally
* because the shared import pipeline is ADIF-based.
*
* Ownership in v2 is enforced against the token's user_id (not the web
* session): we resolve the token owner's station ids and reject anything else.
*
* Route: /api/v2/qso
* Scope: qso:read / qso:write / qso:delete
*/
class Qso_resource extends Api_v2_resource {
/** Token scope of this resource (see Api_v2_resource::required_scope()). */
protected $scope = 'qso';
/** Registry labels for this resource's scopes (see scope_definitions()). */
protected static function scope_labels() {
return [
'read' => __('Read QSOs'),
'write' => __('Create and update QSOs'),
'delete' => __('Delete QSOs'),
];
}
/**
* GET /api/v2/qso
* Filtered list of the token owner's QSOs. The same filtered result set is
* rendered as JSON (default) or as ADIF via ?format=adif — the data is fetched
* once and only the rendering differs.
*
* Filters (all optional):
* ?station_id= comma-separated, ownership-checked; default: all owned
* ?callsign= exact match on the worked callsign (e.g. HB9HIL)
* ?band= single band filter (e.g. 20m or SAT)
* ?mode= single mode/submode filter (e.g. SSB or FT8)
* ?qsl_filter= comma list of lotw|qsl|eqsl|clublog (OR-combined)
* ?since_id= only QSOs with a primary key greater than this (default 0)
* ?qso_since= YYYY-MM-DD, oldest QSO date to include (whole day)
* ?qso_until= YYYY-MM-DD, newest QSO date to include (whole day)
* Pagination: ?page= / ?per_page= (max 5000; default 50 for JSON, 1000 for
* ADIF), or ?limit= as a shortcut for the newest N QSOs (overrides
* page/per_page).
* Rendering: ?format=json (default) | adif.
*/
public function index() {
$format = strtolower(trim((string) $this->param('format', 'json')));
if ($format !== 'json' && $format !== 'adif') {
throw new Api_v2_exception(
'validation_error',
'Unknown format "' . $format . '". Allowed: json, adif',
400,
['allowed' => ['json', 'adif']]
);
}
$this->CI->load->model('logbook_model');
// One upper bound for both formats: a JSON row (format_qso) is a trimmed
// subset of fields and thus no heavier than an ADIF line, so there is no
// reason to cap JSON lower. Only the default page size differs — small for
// JSON browsing, larger for ADIF bulk sync.
$is_adif = ($format === 'adif');
$max_per_page = 5000;
$page = $this->pagination($is_adif ? 1000 : 50, $max_per_page);
$station_ids = $this->resolve_station_ids();
$band = $this->normalize_band($this->param('band'));
$mode = $this->normalize_mode($this->param('mode'));
$callsign = $this->normalize_callsign($this->param('callsign'));
$qsl_filter = $this->parse_qsl_filter();
$since_id = $this->parse_since_id();
$qso_since = $this->parse_date('qso_since');
$qso_until = $this->parse_date('qso_until');
// ADIF sync needs ascending-by-id order (so lastfetchedid advances); the
// JSON browse list is newest-first.
$order = $is_adif ? 'id_asc' : 'time_desc';
// `limit` is a shortcut for "the newest N QSOs" (e.g. limit=1 => the last
// QSO). It overrides page/per_page: page 1, newest-first, capped like
// per_page. Handy for a quick "what did I work last?" query.
$limit = $this->param('limit');
if ($limit !== null && $limit !== '') {
if (!is_numeric($limit) || (int) $limit < 1) {
throw new Api_v2_exception('validation_error', 'limit must be a positive integer', 400);
}
$page['page'] = 1;
$page['offset'] = 0;
$page['per_page'] = min((int) $limit, $max_per_page);
$order = 'time_desc';
}
if (empty($station_ids)) {
$this->respond_qsos($format, [], $page, 0, $since_id);
return;
}
// A club member below officer level only ever sees its own QSOs - in the
// JSON list and the ADIF export alike, since both run the same query.
$operator = $this->is_restricted_club_member() ? (string) $this->operator_callsign() : '';
$filters = [
'station_ids' => $station_ids,
'callsign' => $callsign,
'band' => $band,
'mode' => $mode,
'qsl_filter' => $qsl_filter,
'since_id' => $since_id,
'qso_since' => $qso_since,
'qso_until' => $qso_until,
'operator' => $operator,
];
// Total across all pages for the same filter, so a client can find the
// last page without probing for an empty response.
$total = $this->CI->logbook_model->count_qsos_filtered($filters);
$query = $this->CI->logbook_model->get_qsos_filtered(
$filters, $order, $page['per_page'], $page['offset']
);
$rows = is_object($query) ? $query->result() : [];
$this->respond_qsos($format, $rows, $page, $total, $since_id);
}
/**
* Render the fetched QSO rows in the requested format, with pagination meta.
* JSON returns the QSO objects; ADIF returns { exported, lastfetchedid, adif }
* built from the same rows via AdifHelper.
*/
protected function respond_qsos($format, $rows, $page, $total, $since_id) {
$meta = $this->list_meta($page, count($rows), $total);
if ($format === 'adif') {
$this->CI->load->library('AdifHelper');
$adif = $this->CI->adifhelper->getAdifHeader(
$this->CI->config->item('app_name'),
$this->CI->optionslib->get_option('version'),
$this->CI->optionslib->get_option('adif_version')
);
$lastfetchedid = (int) $since_id;
foreach ($rows as $row) {
$adif .= $this->CI->adifhelper->getAdifLine($row);
$lastfetchedid = max($lastfetchedid, (int) $row->COL_PRIMARY_KEY);
}
$this->CI->api_v2_response->respond([
'exported' => count($rows),
'lastfetchedid' => $lastfetchedid,
'adif' => count($rows) > 0 ? $adif : null,
], 200, $meta);
return;
}
$qsos = [];
foreach ($rows as $row) {
$qsos[] = $this->format_qso($row);
}
$this->CI->api_v2_response->respond($qsos, 200, $meta);
}
/**
* Parse and validate the ?since_id= floor, or 0 when absent.
*/
protected function parse_since_id() {
$raw = $this->param('since_id', 0);
if (!is_numeric($raw)) {
throw new Api_v2_exception('validation_error', 'since_id must be numeric', 400);
}
return (int) $raw;
}
/**
* Parse and validate the ?qsl_filter= query (comma list), or null when absent.
* Mirrors the confirmation types the QSO filter can match on (see
* Logbook_model::_qso_v2_filter_where()); HRDLog is upload-only and has no
* received column, so it is not among them.
*/
protected function parse_qsl_filter() {
return $this->parse_type_list('qsl_filter', ['lotw', 'qsl', 'eqsl', 'qrz', 'clublog']);
}
/**
* GET /api/v2/qso/{id}
* A single QSO owned by the key holder.
*/
public function show($id) {
$this->require_numeric_id($id);
$this->CI->load->model('logbook_model');
// 404 unless the QSO belongs to the key owner.
if (!$this->CI->logbook_model->check_qso_is_accessible($id, $this->user_id())) {
throw new Api_v2_exception('not_found', 'QSO not found', 404);
}
$this->require_own_qso($id);
// Trusted read: ownership already verified against the key's user_id.
$query = $this->CI->logbook_model->get_qso($id, true);
if ($query === null || $query->num_rows() === 0) {
throw new Api_v2_exception('not_found', 'QSO not found', 404);
}
$this->CI->api_v2_response->respond($this->format_qso($query->row()));
}
/**
* POST /api/v2/qso
* Create QSO(s). The body field `import_type` selects the payload format:
* - "json" (default): a single QSO from the top-level fields, OR a bulk
* import when a `qsos` array is present (each element a QSO object).
* Required per QSO: call, band, mode, qso_date (YYYY-MM-DD), time_on.
* `station_profile_id` (shared) is required at the top level; bulk accepts
* an optional `dryrun`.
* - "adif": a bulk ADIF import; the ADIF payload travels in the `adif` field,
* with `station_profile_id` and an optional `dryrun` flag.
*/
public function create() {
$this->require_write();
$this->CI->load->model('logbook_model');
$this->CI->load->model('stations');
$body = $this->body();
// Ownership-checked station profile is required for both import types.
// The body-wide scalar check runs later (and not at all for the bulk
// variant, whose "qsos" key is an array), so guard the type here before
// the value reaches the data layer.
$station_profile_id = $body['station_profile_id'] ?? null;
if (!is_numeric($station_profile_id)
|| !$this->CI->stations->check_station_against_user((int) $station_profile_id, $this->user_id())) {
throw new Api_v2_exception('forbidden', 'station_profile_id does not belong to the API key owner', 403);
}
$import_type = strtolower(trim((string) ($body['import_type'] ?? 'json')));
switch ($import_type) {
case 'json':
$this->create_from_json($body, $station_profile_id);
return;
case 'adif':
$this->create_from_adif($body, $station_profile_id, !empty($body['dryrun']));
return;
default:
throw new Api_v2_exception(
'validation_error',
'Unknown import_type "' . $import_type . '". Allowed: json, adif',
400,
['allowed' => ['json', 'adif']]
);
}
}
/**
* Create QSO(s) from JSON (import_type=json, the default). A "qsos" array in
* the body triggers a bulk import; otherwise a single QSO is created from the
* top-level fields.
*/
protected function create_from_json($body, $station_profile_id) {
if (array_key_exists('qsos', $body)) {
$this->create_bulk_json($body, $station_profile_id);
return;
}
$this->require_scalar_fields($body);
// Minimal required-field validation; the model handles the rest.
$required = ['call', 'band', 'mode', 'qso_date', 'time_on'];
$missing = [];
foreach ($required as $field) {
if (empty($body[$field])) {
$missing[] = $field;
}
}
if (!empty($missing)) {
throw new Api_v2_exception(
'validation_error',
'Missing required field(s): ' . implode(', ', $missing),
400,
['missing' => $missing]
);
}
// Build an ADIF-style record (lowercase keys) for the import pipeline.
$record = $this->force_club_operator($this->body_to_record($body));
// apicall = true and skipStationCheck = true: we already verified the
// station belongs to the key owner above, so skip the session-based check.
$result = $this->CI->logbook_model->import_bulk(
[$record],
$station_profile_id,
true, // skipDuplicate
false, false, false, false, false, false, false,
false, // skipexport
false, // operatorName
true, // apicall
true // skipStationCheck
);
if (($result['qsocount'] ?? 0) < 1) {
throw new Api_v2_exception(
'validation_error',
trim(strip_tags($result['errormessage'] ?? 'QSO could not be created')),
400,
$result['structured_errors'] ?? null
);
}
// Resolve the freshly inserted QSO by primary key for the response.
$new_id = $result['inserted_id'] ?? null;
$created = null;
if ($new_id) {
$query = $this->CI->logbook_model->get_qso($new_id, true);
if ($query !== null && $query->num_rows() > 0) {
$created = $this->format_qso($query->row());
}
}
$headers = $new_id ? ['Location' => base_url('index.php/api/v2/qso/' . $new_id)] : [];
$this->CI->api_v2_response->respond($created ?? ['id' => $new_id], 201, null, $headers);
}
/**
* Bulk-create QSOs from a JSON array (import_type=json with a "qsos" array).
* Each element is a QSO object with the same fields as a single create; they
* are all imported into the given, ownership-checked station_profile_id. An
* optional top-level "dryrun" validates without importing.
*
* @param array $body Decoded request body (must contain "qsos").
* @param int $station_profile_id Ownership-checked target station.
*/
protected function create_bulk_json($body, $station_profile_id) {
$qsos = $body['qsos'];
// Must be a non-empty JSON array (a list, not an object).
if (!is_array($qsos) || empty($qsos) || array_keys($qsos) !== range(0, count($qsos) - 1)) {
throw new Api_v2_exception('validation_error', '"qsos" must be a non-empty array of QSO objects', 400);
}
$required = ['call', 'band', 'mode', 'qso_date', 'time_on'];
$records = [];
foreach ($qsos as $i => $qso) {
if (!is_array($qso)) {
throw new Api_v2_exception('validation_error', 'qsos[' . $i . '] must be an object', 400);
}
$this->require_scalar_fields($qso);
$missing = [];
foreach ($required as $field) {
if (empty($qso[$field])) {
$missing[] = $field;
}
}
if (!empty($missing)) {
throw new Api_v2_exception(
'validation_error',
'qsos[' . $i . '] missing required field(s): ' . implode(', ', $missing),
400,
['index' => $i, 'missing' => $missing]
);
}
$records[] = $this->force_club_operator($this->body_to_record($qso));
}
if (!empty($body['dryrun'])) {
$this->CI->api_v2_response->respond(['dryrun' => true, 'parsed' => count($records)], 200);
return;
}
$result = $this->CI->logbook_model->import_bulk(
$records,
$station_profile_id,
true, // skipDuplicate
false, false, false, false, false, false, false,
false, // skipexport
false, // operatorName
true, // apicall
true // skipStationCheck (ownership already verified)
);
$this->respond_bulk_import($result, count($records));
}
/**
* Emit the response for a bulk import (ADIF or JSON multi-QSO): a summary of
* parsed / imported / skipped counts plus any messages. Throws 400 when
* nothing was imported and only hard errors occurred.
*
* @param array $result Return value of Logbook_model::import_bulk().
* @param int $parsed Number of records handed to the import.
*/
protected function respond_bulk_import($result, $parsed) {
$imported = (int) ($result['qsocount'] ?? 0);
$structured = $result['structured_errors'] ?? ['critical' => [], 'validation' => [], 'duplicate' => []];
$skipped = count($structured['duplicate'] ?? []);
$hard_errors = array_merge($structured['critical'] ?? [], $structured['validation'] ?? []);
// Nothing imported and only hard errors -> the whole batch failed.
if ($imported === 0 && !empty($hard_errors)) {
throw new Api_v2_exception(
'validation_error',
trim(strip_tags($result['errormessage'] ?? 'Import failed')),
400,
$structured
);
}
$this->CI->api_v2_response->respond([
'parsed' => $parsed,
'imported' => $imported,
'skipped' => $skipped,
'messages' => array_values(array_map(function ($m) { return trim(strip_tags($m)); }, $hard_errors)),
], 201);
}
/**
* Bulk-import QSOs from an ADIF string (import_type=adif).
*
* Reuses the same parse/import pipeline as the v1 API: adif_parser feeds
* records into Logbook_model::import_bulk(). Clubstation operator resolution
* mirrors v1 — when a club member's token is used, the operator is forced to
* the token creator rather than the club callsign.
*
* @param array $body Decoded request body.
* @param int $station_profile_id Ownership-checked target station.
* @param bool $dryrun Parse only, import nothing.
*/
protected function create_from_adif($body, $station_profile_id, $dryrun) {
$adif = $body['adif'] ?? '';
if (!is_string($adif) || trim($adif) === '') {
throw new Api_v2_exception('validation_error', 'import_type=adif requires a non-empty "adif" string', 400);
}
if (!$this->CI->load->is_loaded('adif_parser')) {
$this->CI->load->library('adif_parser');
}
if (!$this->CI->load->is_loaded('Qra')) {
$this->CI->load->library('Qra');
}
$profile = $this->CI->stations->profile_clean($station_profile_id);
$mygrid = $profile->station_gridsquare ?? '';
// Collapse whitespace right after <eor> so the parser is not tripped up
// by pretty-printed ADIF, matching the v1 endpoint.
$adif = preg_replace('#<([eE][oO][rR])>[\r\n\t]+#', '<$1>', $adif);
$this->CI->adif_parser->feed($adif);
$records = [];
$parsed = 0;
while ($record = $this->CI->adif_parser->get_record()) {
if (!isset($record['call']) || trim($record['call']) === '') {
continue;
}
if (count($record) === 0) {
break;
}
// Normalise slashed zeros in the callsign fields.
$record['call'] = str_replace('Ø', '0', $record['call']);
foreach (['operator', 'station_callsign', 'owner_callsign'] as $f) {
if (($record[$f] ?? '') !== '') {
$record[$f] = str_replace('Ø', '0', $record[$f]);
}
}
$record = $this->force_club_operator($record);
// Fill the distance from the station's own grid when possible.
if (array_key_exists('gridsquare', $record) && $mygrid !== ''
&& ($record['gridsquare'] ?? '') !== '' && !array_key_exists('distance', $record)) {
$record['distance'] = $this->CI->qra->distance($mygrid, $record['gridsquare'], 'K');
}
$records[] = $record;
$parsed++;
}
if ($dryrun) {
$this->CI->api_v2_response->respond(['dryrun' => true, 'parsed' => $parsed], 200);
return;
}
if (empty($records)) {
throw new Api_v2_exception('validation_error', 'No valid QSO records found in ADIF', 400);
}
$result = $this->CI->logbook_model->import_bulk(
$records,
$station_profile_id,
true, // skipDuplicate
false, false, false, false, false, false, false,
true, // skipexport
false, // operatorName
true, // apicall
true // skipStationCheck (ownership already verified above)
);
$this->respond_bulk_import($result, $parsed);
}
/**
* PATCH /api/v2/qso/{id}
* Partial update: only the fields present in the body are changed.
*/
public function update($id) {
$this->apply_update($id);
}
/**
* DELETE /api/v2/qso/{id}
*/
public function delete($id) {
$this->require_delete();
$this->require_numeric_id($id);
$this->CI->load->model('logbook_model');
// 404 unless the QSO belongs to the key owner.
if (!$this->CI->logbook_model->check_qso_is_accessible($id, $this->user_id())) {
throw new Api_v2_exception('not_found', 'QSO not found', 404);
}
$this->require_own_qso($id);
// The model handles the full teardown (OQRS, QSL/eQSL images, cache)
// and re-checks ownership against the same user_id.
$this->CI->logbook_model->delete($id, $this->user_id());
$this->CI->api_v2_response->no_content();
}
// --- Internal helpers --------------------------------------------------
/**
* Resolve the operator of an inbound QSO record for clubstation tokens.
*
* A club token logs into the clubstation's logbook, but the QSO belongs to
* the member who made it - COL_OPERATOR must therefore carry the member's
* own callsign, not the shared club call. Two rules, mirroring the v1 API:
*
* - Any club token: an absent operator, or one that merely repeats the club
* callsign, is filled in with the member's callsign.
* - Below officer level (3 and 6): the operator is overwritten
* unconditionally. A member must not be able to log under someone else's
* callsign by simply naming them in the payload.
*
* Applied to every create path (single JSON, bulk JSON and ADIF) so the
* payload format cannot decide whether the rule holds.
*
* @param array $record ADIF-style record (lowercase keys).
* @return array The record with the operator resolved.
*/
protected function force_club_operator($record) {
// null for a personal token, and for every token when the clubstation
// feature is off (resolve_club_context() gates on it).
$operator = $this->operator_callsign();
if ($operator === null) {
return $record;
}
$recorded = $record['operator'] ?? '';
if ($recorded === '' || $recorded == ($record['station_callsign'] ?? '')) {
$record['operator'] = $operator;
}
if ($this->is_restricted_club_member()) {
$record['operator'] = $operator;
}
return $record;
}
/**
* Guard a per-QSO write for club members below officer level: they may only
* touch QSOs they made themselves.
*
* check_qso_is_accessible() only proves the QSO sits in a station location
* of the token owner - and for a club token the owner *is* the clubstation,
* so every QSO in the club log passes it. The operator is what separates one
* member's QSOs from another's, which is exactly what
* clubaccess_filter_qso_ids() checks for the batch flows in the web UI.
*
* A foreign QSO is reported as 404 rather than 403, matching how the rest of
* this resource treats a QSO the token may not see: not confirming that it
* exists at all.
*
* @param int $id QSO primary key, already known to be accessible.
* @throws Api_v2_exception 404 when the QSO belongs to another operator.
*/
protected function require_own_qso($id) {
if (!$this->is_restricted_club_member()) {
return;
}
$query = $this->CI->logbook_model->get_qso($id, true);
$row = ($query !== null && $query->num_rows() > 0) ? $query->row() : null;
// Compared case-insensitively: the import pipeline uppercases the
// operator, but older rows may hold it as typed.
if ($row === null || strcasecmp((string) $row->COL_OPERATOR, (string) $this->operator_callsign()) !== 0) {
throw new Api_v2_exception('not_found', 'QSO not found', 404);
}
}
/**
* PATCH implementation.
*
* @param int $id QSO primary key from the path.
*/
protected function apply_update($id) {
$this->require_write();
$this->require_numeric_id($id);
$this->CI->load->model('logbook_model');
// 404 unless the QSO belongs to the token owner.
if (!$this->CI->logbook_model->check_qso_is_accessible($id, $this->user_id())) {
throw new Api_v2_exception('not_found', 'QSO not found', 404);
}
$this->require_own_qso($id);
$body = $this->body();
$this->require_scalar_fields($body);
$data = $this->build_update_data($body);
// Optional move to another station location, ownership-checked.
if (array_key_exists('station_profile_id', $body)) {
$this->CI->load->model('stations');
$station_profile_id = $body['station_profile_id'];
if (!$this->CI->stations->check_station_against_user($station_profile_id, $this->user_id())) {
throw new Api_v2_exception('forbidden', 'station_profile_id does not belong to the API token owner', 403);
}
$profile = $this->CI->stations->profile_clean($station_profile_id);
$data['station_id'] = $station_profile_id;
$data['COL_STATION_CALLSIGN'] = trim($profile->station_callsign);
}
if (empty($data)) {
throw new Api_v2_exception('validation_error', 'No editable fields in request body', 400);
}
$this->CI->logbook_model->update_qso_columns($id, $data);
// Respond with the fresh state of the QSO.
$query = $this->CI->logbook_model->get_qso($id, true);
$this->CI->api_v2_response->respond($this->format_qso($query->row()));
}
/**
* Build the COL_* update array from the JSON body. Only fields present in
* the body are touched; anything omitted keeps its stored value.
*/
protected function build_update_data($body) {
$data = [];
// Date/time: qso_date and time_on always travel together.
$has_date = array_key_exists('qso_date', $body);
$has_time = array_key_exists('time_on', $body);
if ($has_date !== $has_time) {
throw new Api_v2_exception('validation_error', 'qso_date and time_on must be supplied together', 400);
}
if ($has_date) {
$time_on = $this->parse_datetime($body['qso_date'], $body['time_on']);
$data['COL_TIME_ON'] = $time_on;
// time_off defaults to time_on and is clamped to >= time_on,
// same as the web UI edit path.
$time_off = isset($body['time_off']) && $body['time_off'] !== ''
? $this->parse_datetime($body['qso_date'], $body['time_off'])
: $time_on;
if (strtotime($time_off) < strtotime($time_on)) {
$time_off = $time_on;
}
$data['COL_TIME_OFF'] = $time_off;
}
// Mode: split into main mode / submode like the web UI does.
if (array_key_exists('mode', $body)) {
$main_mode = $this->CI->logbook_model->get_main_mode_if_submode($body['mode']);
if ($main_mode == null) {
$data['COL_MODE'] = $body['mode'];
$data['COL_SUBMODE'] = null;
} else {
$data['COL_MODE'] = $main_mode;
$data['COL_SUBMODE'] = $body['mode'];
}
}
// Frequencies are normalised to Hz.
foreach (['freq' => 'COL_FREQ', 'freq_rx' => 'COL_FREQ_RX'] as $key => $col) {
if (array_key_exists($key, $body)) {
$data[$col] = $this->CI->logbook_model->parse_frequency($body[$key]);
}
}
if (array_key_exists('call', $body) && !$this->CI->logbook_model->is_valid_callsign($body['call'])) {
throw new Api_v2_exception('validation_error', 'Invalid callsign', 400, ['field' => 'call']);
}
// Simple whitelisted fields, only those present in the body.
foreach ($this->editable_fields() as $key => $def) {
list($col, $upper) = $def;
if (array_key_exists($key, $body)) {
$value = $body[$key];
if ($upper && is_string($value)) {
$value = strtoupper(trim($value));
}
$data[$col] = $value;
}
}
return $data;
}
/**
* Editable simple fields: json key => [column, uppercase].
* Date/time, mode and frequencies are handled separately above.
*/
protected static function editable_fields() {
return [
'call' => ['COL_CALL', true],
'band' => ['COL_BAND', false],
'band_rx' => ['COL_BAND_RX', false],
'rst_sent' => ['COL_RST_SENT', false],
'rst_rcvd' => ['COL_RST_RCVD', false],
'gridsquare' => ['COL_GRIDSQUARE', true],
'name' => ['COL_NAME', false],
'comment' => ['COL_COMMENT', false],
'notes' => ['COL_NOTES', false],
'qth' => ['COL_QTH', false],
'tx_pwr' => ['COL_TX_PWR', false],
'prop_mode' => ['COL_PROP_MODE', false],
'sat_name' => ['COL_SAT_NAME', true],
'sat_mode' => ['COL_SAT_MODE', true],
'sota_ref' => ['COL_SOTA_REF', true],
'pota_ref' => ['COL_POTA_REF', true],
'wwff_ref' => ['COL_WWFF_REF', true],
'iota' => ['COL_IOTA', true],
'sig' => ['COL_SIG', true],
'sig_info' => ['COL_SIG_INFO', true],
'darc_dok' => ['COL_DARC_DOK', true],
'state' => ['COL_STATE', false],
'cnty' => ['COL_CNTY', false],
'cqz' => ['COL_CQZ', false],
'ituz' => ['COL_ITUZ', false],
'qsl_via' => ['COL_QSL_VIA', false],
'srx' => ['COL_SRX', false],
'stx' => ['COL_STX', false],
'srx_string' => ['COL_SRX_STRING', true],
'stx_string' => ['COL_STX_STRING', true],
];
}
/**
* Combine an ADIF-style date (YYYY-MM-DD) and time (HHMM[SS] or HH:MM[:SS])
* into a datetime string, or throw a 400 on garbage input.
*/
protected function parse_datetime($date, $time) {
// Normalise compact ADIF times (1200 / 120030) to colon notation.
if (preg_match('/^\d{4}$/', $time)) {
$time = substr($time, 0, 2) . ':' . substr($time, 2, 2);
} elseif (preg_match('/^\d{6}$/', $time)) {
$time = substr($time, 0, 2) . ':' . substr($time, 2, 2) . ':' . substr($time, 4, 2);
}
$timestamp = strtotime($date . ' ' . $time);
if ($timestamp === false) {
throw new Api_v2_exception('validation_error', 'Invalid qso_date/time value', 400);
}
return date('Y-m-d H:i:s', $timestamp);
}
/**
* Validate a path id is a positive integer, else 404.
*/
protected function require_numeric_id($id) {
if (!is_numeric($id) || (int) $id < 1) {
throw new Api_v2_exception('not_found', 'QSO not found', 404);
}
}
/**
* Map incoming JSON fields to ADIF-style record keys understood by
* Logbook_model::import(). Unknown keys are passed through unchanged, so
* any valid ADIF field name (lowercase) works out of the box. Dates and
* times are normalised to the compact ADIF notation the import pipeline
* expects (20260610 / 1200), so ISO input (2026-06-10 / 12:00) works too.
*/
protected function body_to_record($body) {
$record = [];
foreach ($body as $key => $value) {
if ($key === 'station_profile_id') {
continue;
}
$key = strtolower($key);
if ($key === 'freq' || $key === 'freq_rx') {
// JSON input is Hz (same as GET and PATCH). The import pipeline
// is ADIF-based and therefore expects MHz, so convert here -
// this is the only place the two unit worlds meet.
$hz = $this->CI->logbook_model->parse_frequency(is_string($value) ? $value : (string) $value);
$record[$key] = $hz > 0 ? $this->hz_to_mhz($hz) : $value;
continue;
}
if (is_string($value)) {
if ($key === 'qso_date' || $key === 'qso_date_off') {
$value = str_replace('-', '', $value);
} elseif ($key === 'time_on' || $key === 'time_off') {
$value = str_replace(':', '', $value);
}
}
$record[$key] = $value;
}
return $record;
}
/**
* Convert a frequency in Hz to the MHz string the ADIF import expects, with
* up to 1 Hz resolution and no trailing zeros (e.g. 14200000 -> "14.2").
*/
protected function hz_to_mhz($hz) {
return rtrim(rtrim(number_format($hz / 1000000, 6, '.', ''), '0'), '.');
}
/**
* Shape a QSO DB row into the public API representation. Kept deliberately
* small for the reference implementation; extend as needed.
*/
protected function format_qso($row) {
$qso = [];
foreach (self::read_only_fields() as $key => $spec) {
$value = $row->{$spec[0]} ?? null;
$qso[$key] = ($spec[1] === 'integer' && $value !== null) ? (int) $value : $value;
}
// Driven by editable_fields() on purpose: a client must be able to read
// back every field it may write, or a read-modify-write cycle would
// silently drop whatever GET never showed it.
foreach (self::editable_fields() as $key => $spec) {
$value = $row->{$spec[0]} ?? null;
if (in_array($key, self::numeric_fields(), true)) {
$value = ($value === null || $value === '') ? null : (int) $value;
}
$qso[$key] = $value;
}
return $qso;
}
/**
* Fields GET returns but PATCH cannot write: json key => [column, type].
* The identifiers plus the date/mode/frequency group, which POST derives
* from the ADIF payload and apply_update() handles separately.
*/
protected static function read_only_fields() {
return [
'id' => ['COL_PRIMARY_KEY', 'integer'],
'station_id' => ['station_id', 'integer'],
'qso_date' => ['COL_TIME_ON', 'string'],
'mode' => ['COL_MODE', 'string'],
'submode' => ['COL_SUBMODE', 'string'],
'freq' => ['COL_FREQ', 'string'],
'freq_rx' => ['COL_FREQ_RX', 'string'],
];
}
/**
* Editable fields whose column holds a number; the driver hands everything
* back as strings, so these get cast on output.
*/
protected static function numeric_fields() {
return ['cqz', 'ituz', 'srx', 'stx'];
}
}