$row['slug'], 'title' => $row['title'], 'description' => $row['description'] ?? '', 'source' => $row['source'] ?? '', 'source_url' => $row['source_url'] ?? '', 'tags' => json_decode_field($row['tags'] ?? null), 'forked_from' => $row['forked_from'] ?? null, 'image' => $row['image'] ?? null, 'steps' => json_decode_field($row['steps'] ?? null), 'outcome' => $row['outcome'] ?? '', 'practice' => $row['practice'] ?? '', 'author' => $row['author_username'] ?? null, 'author_id' => $row['author_id'] ?? null, 'visibility' => $row['visibility'] ?? 'public', 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } function format_collection(array $row, array $items): array { return [ 'slug' => $row['slug'], 'title' => $row['title'], 'description' => $row['description'] ?? '', 'source' => $row['source'] ?? '', 'image' => $row['image'] ?? null, 'author' => $row['author_username'] ?? null, 'author_id' => $row['author_id'] ?? null, 'visibility' => $row['visibility'] ?? 'public', 'items' => array_map(fn($i) => [ 'type' => $i['item_type'], 'slug' => $i['item_slug'], ], $items), 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } function format_collection_summary(array $row, int $item_count): array { return [ 'slug' => $row['slug'], 'title' => $row['title'], 'description' => $row['description'] ?? '', 'source' => $row['source'] ?? '', 'image' => $row['image'] ?? null, 'author' => $row['author_username'] ?? null, 'author_id' => $row['author_id'] ?? null, 'visibility' => $row['visibility'] ?? 'public', 'item_count' => $item_count, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } // --- Routing --- $parts = parse_path(); $method = $_SERVER['REQUEST_METHOD']; // Empty path = API root if (empty($parts)) { respond(200, [ 'name' => 'Protocol Droid API', 'version' => '0.2.0', 'endpoints' => '/api/protocols, /api/collections, /api/auth/*, /api/users/*', ]); } // --- Auth routes --- if ($parts[0] === 'auth') { $action = $parts[1] ?? ''; if ($action === 'register' && $method === 'POST') handle_register(); elseif ($action === 'login' && $method === 'POST') handle_login(); elseif ($action === 'logout' && $method === 'POST') handle_logout(); elseif ($action === 'me' && $method === 'GET') handle_me(); elseif ($action === 'sso' && $method === 'POST') handle_sso_callback(); else respond(404, ['error' => 'Unknown auth endpoint']); } // --- Config --- if ($parts[0] === 'config' && $method === 'GET') { respond(200, merged_config()); } // --- Protocols --- if ($parts[0] === 'protocols') { $slug = $parts[1] ?? null; if (!$slug) { // List protocols if ($method !== 'GET') respond(405, ['error' => 'Method not allowed']); $where = "WHERE p.visibility = 'public'"; $params = []; $tag = $_GET['tag'] ?? null; if ($tag) { // Use JSON_CONTAINS for accurate tag matching (MySQL 5.7+) $where .= " AND JSON_CONTAINS(p.tags, JSON_QUOTE(?))"; $params[] = $tag; } $q = $_GET['q'] ?? null; if ($q) { $where .= " AND (p.title LIKE ? OR p.description LIKE ? OR p.source LIKE ?)"; $params = array_merge($params, ["%$q%", "%$q%", "%$q%"]); } $sql = "SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id $where ORDER BY p.created_at DESC"; $rows = db_all($sql, $params); respond(200, array_map('format_protocol', $rows)); } // YAML export if (($parts[2] ?? '') === 'yaml' && $method === 'GET') { $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); if (!$row) respond(404, ['error' => 'Protocol not found']); $safe_slug = sanitize_slug($slug); header('Content-Type: text/yaml'); header('Content-Disposition: attachment; filename="' . $safe_slug . '.yaml"'); echo protocol_to_yaml($row); exit; } // Votes for a protocol if (($parts[2] ?? '') === 'votes' && $method === 'GET') { // Check protocol exists and is public (or user is author/admin) $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); if (!$row) respond(404, ['error' => 'Protocol not found']); $user = current_user(); if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) { respond(404, ['error' => 'Protocol not found']); } $votes = db_all( "SELECT v.value, v.comment, v.created_at, u.username FROM votes v LEFT JOIN users u ON v.user_id = u.id WHERE v.protocol_slug = ?", [$slug] ); $score = array_sum(array_column($votes, 'value')); respond(200, ['score' => $score, 'votes' => $votes]); } // Single protocol — enforce visibility if ($method === 'GET') { $row = db_one( "SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.slug = ?", [$slug] ); if (!$row) respond(404, ['error' => 'Protocol not found']); $user = current_user(); if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) { respond(404, ['error' => 'Protocol not found']); } respond(200, format_protocol($row)); } // Create protocol if ($method === 'POST') { $user = require_member(); $data = json_body(); $new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled')); $id = db_insert('protocols', [ 'slug' => $new_slug, 'title' => substr($data['title'] ?? '', 0, 255), 'description' => $data['description'] ?? '', 'source' => $data['source'] ?? ('@' . $user['username']), 'source_url' => substr($data['source_url'] ?? '', 0, 500), 'tags' => json_encode(array_slice($data['tags'] ?? [], 0, 20)), 'forked_from' => $data['forked_from'] ?? null, 'image' => $data['image'] ?? null, 'steps' => json_encode(array_slice($data['steps'] ?? [], 0, 50)), 'outcome' => $data['outcome'] ?? '', 'practice' => $data['practice'] ?? '', 'author_id' => $user['id'], 'visibility' => validate_visibility($data['visibility'] ?? 'public'), ]); $row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?", [$id]); respond(201, format_protocol($row)); } // Update protocol — auth before existence check if ($method === 'PUT') { $user = require_auth(); $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); if (!$row) respond(404, ['error' => 'Protocol not found']); if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { respond(403, ['error' => 'You can only edit your own protocols']); } $data = json_body(); $updates = []; foreach (['title', 'description', 'source', 'source_url', 'forked_from', 'image', 'outcome', 'practice'] as $field) { if (array_key_exists($field, $data)) $updates[$field] = $data[$field]; } if (array_key_exists('tags', $data)) $updates['tags'] = json_encode(array_slice($data['tags'] ?? [], 0, 20)); if (array_key_exists('steps', $data)) $updates['steps'] = json_encode(array_slice($data['steps'] ?? [], 0, 50)); if (array_key_exists('visibility', $data)) $updates['visibility'] = validate_visibility($data['visibility']); if ($updates) { db_update('protocols', $updates, 'slug = ?', [$slug]); } $row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.slug = ?", [$slug]); respond(200, format_protocol($row)); } // Delete protocol — auth before existence check if ($method === 'DELETE') { $user = require_auth(); $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); if (!$row) respond(404, ['error' => 'Protocol not found']); if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { respond(403, ['error' => 'You can only delete your own protocols']); } // Clean up orphaned votes and collection items db_query("DELETE FROM votes WHERE protocol_slug = ?", [$slug]); db_query("DELETE FROM collection_items WHERE item_type = 'protocol' AND item_slug = ?", [$slug]); db_query("DELETE FROM protocols WHERE slug = ?", [$slug]); respond(200, ['ok' => true]); } respond(405, ['error' => 'Method not allowed']); } // --- Collections --- if ($parts[0] === 'collections') { $slug = $parts[1] ?? null; if (!$slug) { // List collections if ($method === 'GET') { $rows = db_all( "SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.visibility = 'public' ORDER BY c.created_at DESC" ); $result = []; foreach ($rows as $row) { $items = db_all("SELECT * FROM collection_items WHERE collection_id = ?", [$row['id']]); $result[] = format_collection_summary($row, count($items)); } respond(200, $result); } // Create collection if ($method === 'POST') { $user = require_member(); $data = json_body(); $new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled')); $id = db_insert('collections', [ 'slug' => $new_slug, 'title' => substr($data['title'] ?? '', 0, 255), 'description' => $data['description'] ?? '', 'source' => $data['source'] ?? ('@' . $user['username']), 'image' => $data['image'] ?? null, 'author_id' => $user['id'], 'visibility' => validate_visibility($data['visibility'] ?? 'public'), ]); $position = 0; foreach (($data['items'] ?? []) as $item) { db_insert('collection_items', [ 'collection_id' => $id, 'item_type' => $item['type'] ?? 'protocol', 'item_slug' => $item['slug'] ?? '', 'position' => $position++, ]); } $row = db_one("SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.id = ?", [$id]); $items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$id]); respond(201, format_collection($row, $items)); } respond(405, ['error' => 'Method not allowed']); } // Single collection — enforce visibility if ($method === 'GET') { $row = db_one( "SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.slug = ?", [$slug] ); if (!$row) respond(404, ['error' => 'Collection not found']); $user = current_user(); if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) { respond(404, ['error' => 'Collection not found']); } $items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$row['id']]); respond(200, format_collection($row, $items)); } // Update collection — auth before existence check if ($method === 'PUT') { $user = require_auth(); $row = db_one("SELECT * FROM collections WHERE slug = ?", [$slug]); if (!$row) respond(404, ['error' => 'Collection not found']); if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { respond(403, ['error' => 'You can only edit your own collections']); } $data = json_body(); $updates = []; foreach (['title', 'description', 'source', 'image'] as $field) { if (array_key_exists($field, $data)) $updates[$field] = $data[$field]; } if (array_key_exists('visibility', $data)) $updates['visibility'] = validate_visibility($data['visibility']); if ($updates) db_update('collections', $updates, 'slug = ?', [$slug]); // Update items if provided if (isset($data['items'])) { $col_id = $row['id']; db_query("DELETE FROM collection_items WHERE collection_id = ?", [$col_id]); $position = 0; foreach ($data['items'] as $item) { db_insert('collection_items', [ 'collection_id' => $col_id, 'item_type' => $item['type'] ?? 'protocol', 'item_slug' => $item['slug'] ?? '', 'position' => $position++, ]); } } $row = db_one("SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.slug = ?", [$slug]); $items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$row['id']]); respond(200, format_collection($row, $items)); } // Delete collection — auth before existence check if ($method === 'DELETE') { $user = require_auth(); $row = db_one("SELECT * FROM collections WHERE slug = ?", [$slug]); if (!$row) respond(404, ['error' => 'Collection not found']); if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { respond(403, ['error' => 'You can only delete your own collections']); } db_query("DELETE FROM collection_items WHERE collection_id = ?", [$row['id']]); db_query("DELETE FROM collections WHERE slug = ?", [$slug]); respond(200, ['ok' => true]); } respond(405, ['error' => 'Method not allowed']); } // --- Users --- if ($parts[0] === 'users') { $username = $parts[1] ?? null; if (!$username || $method !== 'GET') respond(404, ['error' => 'Unknown endpoint']); $user = db_one("SELECT id, username, display_name, bio, created_at FROM users WHERE username = ?", [$username]); if (!$user) respond(404, ['error' => 'User not found']); // Fetch full protocol data for user's public protocols $protocols = db_all( "SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.author_id = ? AND p.visibility = 'public' ORDER BY p.created_at DESC", [$user['id']] ); $collections = db_all("SELECT slug, title, description FROM collections WHERE author_id = ? AND visibility = 'public' ORDER BY created_at DESC", [$user['id']]); respond(200, [ 'user' => $user, 'protocols' => array_map('format_protocol', $protocols), 'collections' => $collections, ]); } // --- Votes --- if ($parts[0] === 'votes' && $method === 'POST') { $user = require_member(); $data = json_body(); $protocol_slug = $data['protocol_slug'] ?? ''; // Constrain value to -1, 0, or 1 $value = (int)($data['value'] ?? 1); $value = $value > 0 ? 1 : ($value < 0 ? -1 : 0); $comment = substr($data['comment'] ?? '', 0, 1000); // Check protocol exists and is public $proto = db_one("SELECT * FROM protocols WHERE slug = ?", [$protocol_slug]); if (!$proto) respond(404, ['error' => 'Protocol not found']); if ($proto['visibility'] !== 'public') respond(404, ['error' => 'Protocol not found']); // Upsert vote db_query( "INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), comment = VALUES(comment)", [$user['id'], $protocol_slug, $value, $comment] ); respond(200, ['ok' => true]); } // --- Import / Export / Seed --- if ($parts[0] === 'export' && $method === 'GET') { require_admin(); $protocols = db_all("SELECT * FROM protocols WHERE visibility = 'public'"); $output = "# Protocol Droid Export\n\n"; foreach ($protocols as $p) { $output .= "---\n\n" . protocol_to_yaml($p) . "\n"; } header('Content-Type: text/yaml'); header('Content-Disposition: attachment; filename="protocol-droid-export.yaml"'); echo $output; exit; } if ($parts[0] === 'import' && $method === 'POST') { require_admin(); $data = json_body(); $yaml_text = $data['yaml'] ?? ''; $parsed = parse_protocol_yaml($yaml_text); if (!$parsed) respond(400, ['error' => 'Could not parse YAML']); $parsed['author_id'] = current_user()['id']; $parsed['slug'] = ensure_unique_slug($parsed['slug']); $parsed['visibility'] = validate_visibility($parsed['visibility'] ?? 'public'); $id = db_insert('protocols', $parsed); respond(201, ['id' => $id, 'slug' => $parsed['slug']]); } if ($parts[0] === 'seed' && $method === 'POST') { require_admin(); $seeds_dir = __DIR__ . '/../seeds'; if (!is_dir($seeds_dir)) respond(404, ['error' => 'No seeds directory']); $count = 0; foreach (glob($seeds_dir . '/*.yaml') as $file) { $yaml_text = file_get_contents($file); $parsed = parse_protocol_yaml($yaml_text); if (!$parsed) continue; if (db_one("SELECT slug FROM protocols WHERE slug = ?", [$parsed['slug']])) continue; db_insert('protocols', $parsed); $count++; } respond(200, ['imported' => $count]); } respond(404, ['error' => 'Unknown endpoint: /' . implode('/', $parts)]);