<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Private in-app notifications. External delivery channels are intentionally
* not activated here; consent, quiet hours and provider policy remain open.
*/
final class Notifications
{
private const SCHEMA_VERSION = '0.2.0';
private const MAX_PAYLOAD_BYTES = 4000;
public static function hooks(): void
{
add_action('rest_api_init', [self::class, 'register_routes']);
// Schema upgrades are write operations. Never run them as a side
// effect of a public page or REST request.
add_action('admin_init', [self::class, 'maybe_upgrade'], 1);
}
public static function install(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$schema_before = self::schema_incomplete();
if (null === $schema_before || !self::prepare_dedupe_key_migration()) {
// A failed schema read or legacy-row normalization is not proof
// that the database is safe to change. Do not continue into
// dbDelta() or update the schema version option.
return;
}
dbDelta("CREATE TABLE " . self::table() . " (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
recipient_user_id bigint(20) unsigned NOT NULL,
type varchar(80) NOT NULL,
target_type varchar(40) NOT NULL DEFAULT '',
target_id bigint(20) unsigned NULL DEFAULT NULL,
payload_json text NOT NULL,
status varchar(20) NOT NULL DEFAULT 'unread',
locale varchar(20) NOT NULL DEFAULT '',
dedupe_key varchar(160) NOT NULL DEFAULT '',
created_at datetime NOT NULL,
read_at datetime NULL DEFAULT NULL,
expires_at datetime NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY recipient_status (recipient_user_id,status,created_at),
KEY recipient_created (recipient_user_id,created_at),
KEY dedupe_key (dedupe_key),
UNIQUE KEY recipient_dedupe (recipient_user_id,dedupe_key)
) {$charset_collate};");
if (false !== self::schema_incomplete()) {
// dbDelta() may have failed or left an unreadable/incomplete
// schema. Never mark the upgrade as complete in that state.
return;
}
update_option('meinweimar_notifications_schema_version', self::SCHEMA_VERSION, false);
}
public static function maybe_upgrade(): void
{
if (!current_user_can('manage_options')) {
return;
}
$schema_incomplete = self::schema_incomplete();
if ($schema_incomplete === null) {
// A failed read is not evidence of a missing schema. Do not let
// an unhealthy database trigger dbDelta() or update_option().
return;
}
global $wpdb;
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$schema_version = get_option('meinweimar_notifications_schema_version', '');
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return;
}
if (self::SCHEMA_VERSION !== (string) $schema_version || $schema_incomplete) {
self::install();
}
}
private static function schema_incomplete(): ?bool
{
global $wpdb;
$table = self::table();
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return null;
}
if ($exists !== $table) {
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($columns)) {
return null;
}
return [] !== array_diff([
'payload_json',
'dedupe_key',
'read_at',
'expires_at',
], $columns);
}
public static function register_routes(): void
{
register_rest_route('meinweimar/v1', '/notifications', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/notifications/(?P<id>\d+)/read', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'mark_read'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
}
public static function create(array $notification): int|WP_Error
{
global $wpdb;
$recipient = absint($notification['recipient_user_id'] ?? 0);
$type = sanitize_key((string) ($notification['type'] ?? ''));
if (!$recipient || !$type) {
return new WP_Error('meinweimar_notification_invalid', __('Die Benachrichtigung ist ungültig.', 'meinweimar-core'), ['status' => 400]);
}
$payload = is_array($notification['payload'] ?? null) ? $notification['payload'] : [];
$payload = self::safe_payload($payload);
$payload_json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload_json) || strlen($payload_json) > self::MAX_PAYLOAD_BYTES) {
return new WP_Error('meinweimar_notification_payload_invalid', __('Die Benachrichtigungsdaten sind ungültig.', 'meinweimar-core'), ['status' => 400]);
}
$dedupe_key = self::text($notification['dedupe_key'] ?? '', 160);
if (!$dedupe_key) {
// Empty keys must remain distinct once recipient-scoped dedupe is
// enforced. Callers that need idempotency provide their own key.
$dedupe_key = 'notification-' . wp_generate_uuid4();
}
if ($dedupe_key) {
$wpdb->last_error = '';
$existing = $wpdb->get_var($wpdb->prepare('SELECT id FROM ' . self::table() . ' WHERE recipient_user_id = %d AND dedupe_key = %s LIMIT 1', $recipient, $dedupe_key));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht geprüft werden.', 'meinweimar-core'), ['status' => 503]);
}
if ($existing) {
return (int) $existing;
}
}
$wpdb->last_error = '';
$inserted = $wpdb->insert(self::table(), [
'recipient_user_id' => $recipient,
'type' => $type,
'target_type' => sanitize_key((string) ($notification['target_type'] ?? '')),
'target_id' => absint($notification['target_id'] ?? 0) ?: null,
'payload_json' => $payload_json,
'status' => 'unread',
'locale' => Settings::sanitize_locale($notification['locale'] ?? ''),
'dedupe_key' => $dedupe_key,
'created_at' => current_time('mysql', true),
'read_at' => null,
'expires_at' => null,
], ['%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
if ($inserted) {
return (int) $wpdb->insert_id;
}
$insert_error = (string) $wpdb->last_error;
if ($dedupe_key && $insert_error !== '') {
// A concurrent request may have won the recipient-scoped unique
// key between the preflight SELECT and this INSERT. Re-read the
// committed row so notification delivery remains idempotent.
$wpdb->last_error = '';
$existing = $wpdb->get_var($wpdb->prepare('SELECT id FROM ' . self::table() . ' WHERE recipient_user_id = %d AND dedupe_key = %s LIMIT 1', $recipient, $dedupe_key));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht geprüft werden.', 'meinweimar-core'), ['status' => 503]);
}
if ($existing) {
return (int) $existing;
}
}
if ($insert_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht gespeichert werden.', 'meinweimar-core'), ['status' => 503]);
}
return new WP_Error('meinweimar_notification_store_failed', __('Die Benachrichtigung konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
public static function list(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$status = sanitize_key((string) $request->get_param('status'));
$limit = max(1, min(100, absint($request->get_param('per_page') ?: 50)));
$where = 'recipient_user_id = %d';
$params = [get_current_user_id()];
if (in_array($status, ['unread', 'read'], true)) {
$where .= ' AND status = %s';
$params[] = $status;
}
$params[] = $limit;
$wpdb->last_error = '';
$rows = $wpdb->get_results($wpdb->prepare('SELECT id, type, target_type, target_id, payload_json, status, locale, created_at, read_at FROM ' . self::table() . ' WHERE ' . $where . ' ORDER BY created_at DESC LIMIT %d', ...$params), ARRAY_A);
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigungen sind derzeit nicht verfügbar.', 'meinweimar-core'), ['status' => 503]);
}
return new WP_REST_Response(['notifications' => array_map([self::class, 'normalize'], is_array($rows) ? $rows : [])]);
}
public static function mark_read(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$id = absint($request['id']);
$wpdb->last_error = '';
$updated = $wpdb->query($wpdb->prepare('UPDATE ' . self::table() . ' SET status = %s, read_at = %s WHERE id = %d AND recipient_user_id = %d', 'read', current_time('mysql', true), $id, get_current_user_id()));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht aktualisiert werden.', 'meinweimar-core'), ['status' => 503]);
}
if (!$updated) {
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SELECT id FROM ' . self::table() . ' WHERE id = %d AND recipient_user_id = %d', $id, get_current_user_id()));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht geprüft werden.', 'meinweimar-core'), ['status' => 503]);
}
if (!$exists) {
return new WP_Error('meinweimar_notification_not_found', __('Benachrichtigung nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
}
return new WP_REST_Response(['id' => $id, 'status' => 'read']);
}
public static function notify_image_suggestion_status(array $suggestion, string $status, string $reason_code = ''): int|WP_Error
{
$recipient = absint($suggestion['submitter_user_id'] ?? 0);
$id = absint($suggestion['id'] ?? 0);
if (!$recipient || !$id) {
return 0;
}
return self::create([
'recipient_user_id' => $recipient,
'type' => 'image_suggestion_status',
'target_type' => (string) ($suggestion['target_type'] ?? ''),
'target_id' => absint($suggestion['target_id'] ?? 0),
'payload' => [
'suggestion_id' => $id,
'status' => sanitize_key($status),
'reason_code' => sanitize_key($reason_code),
],
'dedupe_key' => 'image-suggestion:' . $id . ':' . sanitize_key($status),
]);
}
private static function normalize(array $row): array
{
$payload = json_decode((string) ($row['payload_json'] ?? ''), true);
return [
'id' => absint($row['id'] ?? 0),
'type' => (string) ($row['type'] ?? ''),
'target_type' => (string) ($row['target_type'] ?? ''),
'target_id' => absint($row['target_id'] ?? 0),
'payload' => is_array($payload) ? $payload : [],
'status' => (string) ($row['status'] ?? 'unread'),
'locale' => (string) ($row['locale'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
'read_at' => (string) ($row['read_at'] ?? ''),
];
}
private static function safe_payload(array $payload): array
{
$safe = [];
foreach ($payload as $key => $value) {
if (!is_scalar($value) || !preg_match('/^[a-z0-9_:-]{1,60}$/i', (string) $key)) {
continue;
}
$safe[sanitize_key((string) $key)] = self::text($value, 300);
}
return $safe;
}
private static function prepare_dedupe_key_migration(): bool
{
global $wpdb;
$table = self::table();
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return false;
}
if ($exists !== $table) {
// A fresh install has nothing to normalize; dbDelta() will
// create the table below.
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($columns)) {
return false;
}
if (!in_array('dedupe_key', $columns, true)) {
// Older tables can be safely extended by dbDelta() first; there
// are no dedupe values to normalize yet.
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$duplicates = $wpdb->get_results("SELECT recipient_user_id, dedupe_key FROM {$table} WHERE dedupe_key <> '' GROUP BY recipient_user_id, dedupe_key HAVING COUNT(*) > 1", ARRAY_A);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($duplicates)) {
return false;
}
foreach (is_array($duplicates) ? $duplicates : [] as $duplicate) {
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$rows = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM {$table} WHERE recipient_user_id = %d AND dedupe_key = %s ORDER BY id ASC",
(int) $duplicate['recipient_user_id'],
(string) $duplicate['dedupe_key']
));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($rows)) {
return false;
}
array_shift($rows);
foreach ($rows as $id) {
$wpdb->last_error = '';
$updated = $wpdb->update($table, ['dedupe_key' => 'legacy-' . absint($id)], ['id' => absint($id)], ['%s'], ['%d']);
if (false === $updated || (string) $wpdb->last_error !== '') {
return false;
}
}
}
$wpdb->last_error = '';
$updated = $wpdb->query("UPDATE {$table} SET dedupe_key = CONCAT('legacy-', id) WHERE dedupe_key = ''");
return false !== $updated && (string) $wpdb->last_error === '';
}
private static function text($value, int $max): string
{
if (!is_scalar($value)) {
return '';
}
$value = sanitize_text_field((string) $value);
return function_exists('mb_substr') ? mb_substr($value, 0, $max) : substr($value, 0, $max);
}
private static function table(): string
{
global $wpdb;
return $wpdb->prefix . 'mw_notifications';
}
}
