<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
final class Claims
{
private const SCHEMA_VERSION = '0.1.0';
public static function hooks(): void
{
add_action('rest_api_init', [self::class, 'register_routes']);
}
public static function install(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table();
$charset_collate = $wpdb->get_charset_collate();
dbDelta("CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
object_type varchar(40) NOT NULL,
object_id bigint(20) unsigned NOT NULL,
claimant_user_id bigint(20) unsigned NOT NULL,
organization_name varchar(190) NOT NULL,
claimant_note text NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
decision_note text NOT NULL,
decided_by bigint(20) unsigned NULL DEFAULT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY object_status (object_type,object_id,status),
KEY claimant_status (claimant_user_id,status)
) {$charset_collate};");
update_option('meinweimar_claims_schema_version', self::SCHEMA_VERSION, false);
}
public static function register_routes(): void
{
register_rest_route('meinweimar/v1', '/claims', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list'],
'permission_callback' => [AccountApi::class, 'require_login'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'create'],
'permission_callback' => [AccountApi::class, 'require_login'],
],
]);
register_rest_route('meinweimar/v1', '/claims/(?P<id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'get'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/claims/(?P<id>\d+)/decision', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'decision'],
'permission_callback' => [Permissions::class, 'can_review_claims'],
]);
}
public static function list(): WP_REST_Response
{
global $wpdb;
$where = Permissions::can_review_claims() ? '1=1' : $wpdb->prepare('claimant_user_id = %d', get_current_user_id());
$rows = $wpdb->get_results('SELECT id, object_type, object_id, claimant_user_id, organization_name, status, decision_note, created_at, updated_at FROM ' . self::table() . ' WHERE ' . $where . ' ORDER BY updated_at DESC LIMIT 200', ARRAY_A);
return new WP_REST_Response(['claims' => array_map([self::class, 'normalize'], $rows)]);
}
public static function create(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$payload = $request->get_json_params();
$payload = is_array($payload) ? $payload : $request->get_params();
$route = sanitize_key((string) ($payload['object_type'] ?? 'places'));
$post_type = ContentTypes::post_type_for_route($route);
$post = $post_type ? get_post(absint($payload['object_id'] ?? 0)) : null;
if (!in_array($route, ['places', 'services'], true) || !$post || $post->post_type !== $post_type || 'publish' !== $post->post_status) {
return new WP_Error('meinweimar_claim_not_allowed', __('Dieses Objekt kann nicht beansprucht werden.', 'meinweimar-core'), ['status' => 400]);
}
$organization = sanitize_text_field((string) ($payload['organization_name'] ?? ''));
$note = sanitize_textarea_field((string) ($payload['claimant_note'] ?? ''));
if (!$organization) {
return new WP_Error('meinweimar_claim_missing_organization', __('Ein Organisationsname ist erforderlich.', 'meinweimar-core'), ['status' => 400]);
}
$pending = $wpdb->get_var($wpdb->prepare(
'SELECT id FROM ' . self::table() . ' WHERE object_type = %s AND object_id = %d AND claimant_user_id = %d AND status = %s LIMIT 1',
$route,
$post->ID,
get_current_user_id(),
'pending'
));
if ($pending) {
return new WP_Error('meinweimar_claim_exists', __('Für dieses Objekt liegt bereits eine offene Anfrage vor.', 'meinweimar-core'), ['status' => 409]);
}
$now = current_time('mysql', true);
$inserted = $wpdb->insert(self::table(), [
'object_type' => $route,
'object_id' => $post->ID,
'claimant_user_id' => get_current_user_id(),
'organization_name' => $organization,
'claimant_note' => $note,
'status' => 'pending',
'decision_note' => '',
'created_at' => $now,
'updated_at' => $now,
], ['%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s']);
if (!$inserted) {
return new WP_Error('meinweimar_claim_store_failed', __('Die Claim-Anfrage konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
return new WP_REST_Response(['claim' => self::get_by_id((int) $wpdb->insert_id)], 201);
}
public static function get(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$claim = self::get_by_id(absint($request['id']));
if (!$claim) {
return new WP_Error('meinweimar_claim_not_found', __('Claim-Anfrage nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
if (!Permissions::can_review_claims() && (int) $claim['claimant_user_id'] !== get_current_user_id()) {
return new WP_Error('meinweimar_claim_forbidden', __('Kein Zugriff auf diese Claim-Anfrage.', 'meinweimar-core'), ['status' => 403]);
}
return new WP_REST_Response(['claim' => self::normalize($claim)]);
}
public static function decision(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$claim_id = absint($request['id']);
$payload = $request->get_json_params();
$payload = is_array($payload) ? $payload : $request->get_params();
$status = sanitize_key((string) ($payload['status'] ?? ''));
$claim = self::decide_for_admin($claim_id, $status, (string) ($payload['decision_note'] ?? ''));
if (is_wp_error($claim)) {
return $claim;
}
return new WP_REST_Response(['claim' => $claim]);
}
/**
* Shared decision path for REST clients and the WordPress moderation UI.
* The caller must already have passed the appropriate capability check.
*/
public static function decide_for_admin(int $claim_id, string $status, string $decision_note = ''): array|WP_Error
{
global $wpdb;
if (!self::get_by_id($claim_id)) {
return new WP_Error('meinweimar_claim_not_found', __('Claim-Anfrage nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
if (!in_array($status, ['approved', 'rejected'], true)) {
return new WP_Error('meinweimar_claim_invalid_decision', __('Die Entscheidung muss approved oder rejected sein.', 'meinweimar-core'), ['status' => 400]);
}
$updated = $wpdb->update(self::table(), [
'status' => $status,
'decision_note' => sanitize_textarea_field($decision_note),
'decided_by' => get_current_user_id(),
'updated_at' => current_time('mysql', true),
], ['id' => $claim_id], ['%s', '%s', '%d', '%s'], ['%d']);
if (false === $updated) {
return new WP_Error('meinweimar_claim_update_failed', __('Die Claim-Entscheidung konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
return self::normalize(self::get_by_id($claim_id));
}
/**
* Returns moderation-safe claim rows for the WP-Admin queue.
*/
public static function admin_queue(string $status = ''): array
{
global $wpdb;
$where = '';
$params = [];
if (in_array($status, ['pending', 'approved', 'rejected'], true)) {
$where = ' WHERE status = %s';
$params[] = $status;
}
$sql = 'SELECT id, object_type, object_id, claimant_user_id, organization_name, claimant_note, status, decision_note, decided_by, created_at, updated_at FROM ' . self::table() . $where . ' ORDER BY updated_at DESC LIMIT 200';
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);
return array_map([self::class, 'normalize'], is_array($rows) ? $rows : []);
}
private static function get_by_id(int $claim_id): ?array
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare('SELECT id, object_type, object_id, claimant_user_id, organization_name, status, claimant_note, decision_note, decided_by, created_at, updated_at FROM ' . self::table() . ' WHERE id = %d LIMIT 1', $claim_id), ARRAY_A);
return is_array($row) ? $row : null;
}
private static function normalize(?array $row): array
{
$row = is_array($row) ? $row : [];
return [
'id' => absint($row['id'] ?? 0),
'object_type' => (string) ($row['object_type'] ?? ''),
'object_id' => absint($row['object_id'] ?? 0),
'claimant_user_id' => absint($row['claimant_user_id'] ?? 0),
'organization_name' => (string) ($row['organization_name'] ?? ''),
'claimant_note' => (string) ($row['claimant_note'] ?? ''),
'status' => (string) ($row['status'] ?? 'pending'),
'decision_note' => (string) ($row['decision_note'] ?? ''),
'decided_by' => absint($row['decided_by'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
private static function table(): string
{
global $wpdb;
return $wpdb->prefix . 'mw_claims';
}
}
