"Only POST allowed"]); exit; } // Token from multipart field or Authorization: Bearer $token = $_POST['token'] ?? null; if (!$token) { $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? ''); if (stripos($auth, 'Bearer ') === 0) { $token = substr($auth, 7); } } $tokenRec = $token ? token_get_record($token) : null; if (!$tokenRec || !empty($tokenRec['temp'])) { http_response_code(403); echo json_encode(["error" => "Invalid token"]); exit; } // Upload: multipart 'file' or raw body $uploadedFilePath = null; if (!empty($_FILES['file']) && is_uploaded_file($_FILES['file']['tmp_name'])) { $uploadedFilePath = $_FILES['file']['tmp_name']; } else { $raw = file_get_contents('php://input'); if ($raw !== false && strlen($raw) > 0) { $tmp = tempnam(sys_get_temp_dir(), 'upl_'); file_put_contents($tmp, $raw); $uploadedFilePath = $tmp; } } if (!$uploadedFilePath) { http_response_code(400); echo json_encode(["error" => "No file sent"]); exit; } // Decrypt payload with session key derived from token via HKDF $encData = file_get_contents($uploadedFilePath); if ($encData === false) { http_response_code(500); echo json_encode(["error" => "Failed to read uploaded file"]); exit; } $sessionKey = hkdf_session_key_from_token($token); $jsonData = null; try { $jsonData = aes256_cbc_decrypt_bytes($encData, $sessionKey); } catch (Throwable $e) { $maybeJson = trim($encData); if ($maybeJson === '' || json_decode($maybeJson, true) === null) { log_msg("Decryption failed and not valid JSON. raw len=" . strlen($encData)); http_response_code(400); echo json_encode(["error" => "Invalid encrypted file or not JSON"]); exit; } $jsonData = $maybeJson; } $data = json_decode($jsonData, true); if (!is_array($data)) { http_response_code(400); echo json_encode(["error" => "Invalid JSON"]); exit; } // RBAC: coaches can only upload for their own clients $role = $tokenRec['role'] ?? ''; $entityID = $tokenRec['entityID'] ?? ''; [$pdo, $tmpDb, $lockFp] = qdb_open(true); try { $pdo->beginTransaction(); if ($role === 'coach' && !empty($data['client'])) { foreach ($data['client'] as $c) { if (!isset($c['clientCode'])) continue; $chk = $pdo->prepare("SELECT coachID FROM client WHERE clientCode = :cc"); $chk->execute([':cc' => $c['clientCode']]); $existing = $chk->fetch(PDO::FETCH_ASSOC); if ($existing && $existing['coachID'] !== $entityID) { $pdo->rollBack(); qdb_discard($tmpDb, $lockFp); http_response_code(403); echo json_encode(["error" => "Not authorized to modify client " . $c['clientCode']]); exit; } } } // --- Collect affected IDs --- $questionnaireIds = []; $clientQuestionPairs = []; if (!empty($data['questionnaire'])) { foreach ($data['questionnaire'] as $q) { if (!empty($q['questionnaireID'])) $questionnaireIds[$q['questionnaireID']] = true; } } if (!empty($data['question'])) { foreach ($data['question'] as $q) { if (!empty($q['questionnaireID'])) $questionnaireIds[$q['questionnaireID']] = true; } } if (!empty($data['completed_questionnaire'])) { foreach ($data['completed_questionnaire'] as $c) { if (!empty($c['questionnaireID'])) $questionnaireIds[$c['questionnaireID']] = true; if (isset($c['clientCode'], $c['questionnaireID'])) { $cc = $c['clientCode'] ?: 'undefined'; $qid = $c['questionnaireID'] ?: 'undefined'; $clientQuestionPairs["$cc|$qid"] = true; } } } if (!empty($data['client_answer'])) { foreach ($data['client_answer'] as $a) { if (!isset($a['clientCode'])) continue; $cc = $a['clientCode'] ?: 'undefined'; if (isset($a['questionID']) && is_string($a['questionID'])) { $parts = explode('-', $a['questionID'], 2); if ($parts[0] !== '') { $questionnaireIds[$parts[0]] = true; $clientQuestionPairs["$cc|{$parts[0]}"] = true; } } } } if (!empty($data['client']) && !empty($data['questionnaire'])) { foreach ($data['client'] as $c) { if (!isset($c['clientCode'])) continue; $cc = $c['clientCode'] ?: 'undefined'; foreach ($data['questionnaire'] as $q) { if (!isset($q['questionnaireID'])) continue; $qid = $q['questionnaireID'] ?: 'undefined'; $clientQuestionPairs["$cc|$qid"] = true; } } } // --- Deletions (dependents first) --- $delAnswersByPairStmt = $pdo->prepare( "DELETE FROM client_answer WHERE clientCode = :cc AND questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qid)" ); $delCompletedByPairStmt = $pdo->prepare( "DELETE FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qid" ); foreach ($clientQuestionPairs as $pair => $_) { [$cc, $qid] = explode('|', $pair, 2); try { $delAnswersByPairStmt->execute([':cc' => $cc, ':qid' => $qid]); $delCompletedByPairStmt->execute([':cc' => $cc, ':qid' => $qid]); log_msg("Deleted answers & completed for client='$cc' questionnaire='$qid'"); } catch (Exception $e) { log_msg("Delete error for pair $pair: " . $e->getMessage()); } } if (!empty($questionnaireIds)) { $delAOStmt = $pdo->prepare( "DELETE FROM answer_option WHERE questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qid)" ); $delQTransStmt = $pdo->prepare( "DELETE FROM question_translation WHERE questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qid)" ); $delQuestionsStmt = $pdo->prepare("DELETE FROM question WHERE questionnaireID = :qid"); foreach ($questionnaireIds as $qid => $_) { try { $delAOStmt->execute([':qid' => $qid]); $delQTransStmt->execute([':qid' => $qid]); $delQuestionsStmt->execute([':qid' => $qid]); log_msg("Deleted questions/options/translations for questionnaireID='$qid'"); } catch (Exception $e) { log_msg("Delete questions error for $qid: " . $e->getMessage()); } } } // --- Inserts/Upserts --- if (!empty($data['client'])) { $stmt = $pdo->prepare( "INSERT OR REPLACE INTO client (clientCode, coachID) VALUES (:cc, :cid)" ); foreach ($data['client'] as $c) { if (!isset($c['clientCode'])) continue; $cc = $c['clientCode'] ?: 'undefined'; $cid = $c['coachID'] ?? $entityID; $stmt->execute([':cc' => $cc, ':cid' => $cid]); } } if (!empty($data['questionnaire'])) { $stmt = $pdo->prepare( "INSERT OR REPLACE INTO questionnaire (questionnaireID, name, version, state) VALUES (:qid, :name, :ver, :state)" ); foreach ($data['questionnaire'] as $q) { if (!isset($q['questionnaireID'])) continue; $stmt->execute([ ':qid' => $q['questionnaireID'] ?: 'undefined', ':name' => $q['name'] ?? '', ':ver' => $q['version'] ?? '', ':state' => $q['state'] ?? '', ]); } } if (!empty($data['question'])) { $stmt = $pdo->prepare( "INSERT OR IGNORE INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired) VALUES (:qid, :qqid, :dt, :t, :oi, :ir)" ); foreach ($data['question'] as $q) { if (!isset($q['questionID'])) continue; $stmt->execute([ ':qid' => $q['questionID'] ?: 'undefined', ':qqid' => $q['questionnaireID'] ?? 'undefined', ':dt' => $q['defaultText'] ?? '', ':t' => $q['type'] ?? '', ':oi' => (int)($q['orderIndex'] ?? 0), ':ir' => (int)($q['isRequired'] ?? 0), ]); } } if (!empty($data['answer_option'])) { $stmt = $pdo->prepare( "INSERT OR IGNORE INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex) VALUES (:aoid, :qid, :dt, :pts, :oi)" ); foreach ($data['answer_option'] as $ao) { if (!isset($ao['answerOptionID'])) continue; $stmt->execute([ ':aoid' => $ao['answerOptionID'], ':qid' => $ao['questionID'] ?? 'undefined', ':dt' => $ao['defaultText'] ?? '', ':pts' => (int)($ao['points'] ?? 0), ':oi' => (int)($ao['orderIndex'] ?? 0), ]); } } if (!empty($data['client_answer'])) { $stmt = $pdo->prepare( "INSERT OR REPLACE INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)" ); foreach ($data['client_answer'] as $a) { if (!isset($a['clientCode'], $a['questionID'])) continue; $stmt->execute([ ':cc' => $a['clientCode'] ?: 'undefined', ':qid' => $a['questionID'] ?: 'undefined', ':aoid' => $a['answerOptionID'] ?? null, ':ftv' => $a['freeTextValue'] ?? null, ':nv' => isset($a['numericValue']) ? (float)$a['numericValue'] : null, ':at' => isset($a['answeredAt']) ? (int)$a['answeredAt'] : null, ]); } } if (!empty($data['completed_questionnaire'])) { $stmt = $pdo->prepare( "INSERT OR REPLACE INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) VALUES (:cc, :qid, :abc, :st, :sa, :ca, :sp)" ); foreach ($data['completed_questionnaire'] as $c) { if (!isset($c['clientCode'], $c['questionnaireID'])) continue; $stmt->execute([ ':cc' => $c['clientCode'] ?: 'undefined', ':qid' => $c['questionnaireID'] ?: 'undefined', ':abc' => $c['assignedByCoach'] ?? null, ':st' => $c['status'] ?? '', ':sa' => isset($c['startedAt']) ? (int)$c['startedAt'] : null, ':ca' => isset($c['completedAt']) ? (int)$c['completedAt'] : null, ':sp' => isset($c['sumPoints']) ? (int)$c['sumPoints'] : null, ]); } } if (!empty($data['question_translation'])) { $stmt = $pdo->prepare( "INSERT OR REPLACE INTO question_translation (questionID, languageCode, text) VALUES (:qid, :lc, :txt)" ); foreach ($data['question_translation'] as $qt) { if (!isset($qt['questionID'], $qt['languageCode'])) continue; $stmt->execute([ ':qid' => $qt['questionID'], ':lc' => $qt['languageCode'], ':txt' => $qt['text'] ?? '', ]); } } if (!empty($data['answer_option_translation'])) { $stmt = $pdo->prepare( "INSERT OR REPLACE INTO answer_option_translation (answerOptionID, languageCode, text) VALUES (:aoid, :lc, :txt)" ); foreach ($data['answer_option_translation'] as $aot) { if (!isset($aot['answerOptionID'], $aot['languageCode'])) continue; $stmt->execute([ ':aoid' => $aot['answerOptionID'], ':lc' => $aot['languageCode'], ':txt' => $aot['text'] ?? '', ]); } } $pdo->commit(); $pdo = null; qdb_save($tmpDb, $lockFp); @unlink($uploadedFilePath); echo json_encode(["success" => true, "message" => "Delta applied and DB saved"]); exit; } catch (Throwable $inner) { qdb_discard($tmpDb, $lockFp); @unlink($uploadedFilePath ?? ''); log_msg("Inner exception: " . $inner->getMessage()); http_response_code(500); error_log($inner->getMessage()); echo json_encode(["error" => "Save error"]); exit; } } catch (Throwable $e) { log_msg("Top-level exception: " . $e->getMessage()); http_response_code(500); error_log($e->getMessage()); echo json_encode(["error" => "Server error"]); exit; }