familyHub/includes/chore_helpers.php
Louis Whittington f14de0b7e1 Add NFC chore submission feature and enhance family settings
- Introduced NFC support for chore submissions, allowing specific person credit after Head of Household approval.
- Updated family settings to include NFC base URL, scan cooldown, and confirmation page options.
- Enhanced chore management with options for anyone to complete chores and NFC link generation.
- Improved API endpoints for handling NFC tokens and chore submissions.
- Updated readme to reflect new NFC features and settings.
2026-03-31 10:28:27 -05:00

251 lines
7.0 KiB
PHP

<?php
require_once __DIR__ . '/persona.php';
const CHORE_SCHEDULE_ONCE = 'once';
const CHORE_SCHEDULE_RECURRING = 'recurring';
const CHORE_NFC_HASH_ALGO = 'sha256';
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeChoresList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array<int, string>
*/
function legacyAssigneeNameToIds(string $name, array $people): array {
$name = trim($name);
if ($name === '') {
return [];
}
$lower = mb_strtolower($name, 'UTF-8');
$ids = [];
foreach ($people as $p) {
$pn = trim((string) ($p['name'] ?? ''));
if ($pn !== '' && mb_strtolower($pn, 'UTF-8') === $lower) {
$ids[] = (string) $p['id'];
}
}
return $ids;
}
/**
* @param array<string, mixed> $c
* @param array<int, array<string, mixed>> $people
* @return array<string, mixed>
*/
function migrateLegacyChoreRow(array $c, array $people): array {
if (isset($c['name']) && !isset($c['title'])) {
$c['title'] = (string) $c['name'];
}
if (!isset($c['assignee_ids']) || !is_array($c['assignee_ids'])) {
if (isset($c['assignee'])) {
$c['assignee_ids'] = legacyAssigneeNameToIds((string) $c['assignee'], $people);
} else {
$c['assignee_ids'] = [];
}
}
$ids = [];
foreach ($c['assignee_ids'] as $id) {
if (is_string($id) && $id !== '') {
$ids[] = $id;
}
}
$c['assignee_ids'] = array_values(array_unique($ids));
if (!isset($c['lists']) || !is_array($c['lists'])) {
$c['lists'] = [];
}
$c['lists'] = normalizeChoreLists($c['lists']);
if (!isset($c['description'])) {
$c['description'] = '';
}
if (!isset($c['image'])) {
$c['image'] = '';
}
if (!isset($c['author_id'])) {
$c['author_id'] = '';
}
if (!isset($c['value'])) {
$c['value'] = 0;
}
$c['value'] = is_numeric($c['value']) ? (float) $c['value'] : 0.0;
if (!isset($c['due_date'])) {
$c['due_date'] = '';
}
$c['due_date'] = is_string($c['due_date']) ? trim($c['due_date']) : '';
if ($c['due_date'] !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $c['due_date'])) {
$c['due_date'] = '';
}
$sched = $c['schedule'] ?? CHORE_SCHEDULE_ONCE;
$c['schedule'] = $sched === CHORE_SCHEDULE_RECURRING ? CHORE_SCHEDULE_RECURRING : CHORE_SCHEDULE_ONCE;
if (!isset($c['recurrence_days']) || !is_numeric($c['recurrence_days'])) {
$c['recurrence_days'] = 7;
}
$c['recurrence_days'] = max(1, (int) $c['recurrence_days']);
if (!isset($c['status'])) {
$c['status'] = 'active';
}
$st = (string) $c['status'];
$c['status'] = in_array($st, ['active', 'completed'], true) ? $st : 'active';
if (!array_key_exists('pending_submission', $c)) {
$c['pending_submission'] = null;
}
if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) {
$c['pending_submission'] = null;
}
$c['anyone_can_complete'] = !empty($c['anyone_can_complete']);
$c['nfc'] = normalizeChoreNfcMeta($c['nfc'] ?? null);
return $c;
}
/**
* @param mixed $raw
* @return array<string, mixed>
*/
function normalizeChoreNfcMeta($raw): array {
if (!is_array($raw)) {
return [
'token_hash' => '',
'enabled' => false,
'created_at' => '',
'last_used_at' => null,
'last_used_ip' => null,
];
}
$tokenHash = isset($raw['token_hash']) ? trim((string) $raw['token_hash']) : '';
$createdAt = isset($raw['created_at']) ? trim((string) $raw['created_at']) : '';
$enabled = !empty($raw['enabled']) && $tokenHash !== '';
$lastUsedAt = isset($raw['last_used_at']) ? trim((string) $raw['last_used_at']) : '';
$lastUsedIp = isset($raw['last_used_ip']) ? trim((string) $raw['last_used_ip']) : '';
return [
'token_hash' => $tokenHash,
'enabled' => $enabled,
'created_at' => $createdAt,
'last_used_at' => $lastUsedAt !== '' ? $lastUsedAt : null,
'last_used_ip' => $lastUsedIp !== '' ? $lastUsedIp : null,
];
}
function generateOpaqueToken(int $bytes = 24): string {
$raw = random_bytes($bytes);
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
function hashOpaqueToken(string $token): string {
return hash(CHORE_NFC_HASH_ALGO, $token);
}
function validateTokenHash(string $token, string $storedHash): bool {
if ($token === '' || $storedHash === '') {
return false;
}
return hash_equals($storedHash, hashOpaqueToken($token));
}
function requestClientAddress(): ?string {
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
return $ip !== '' ? $ip : null;
}
/**
* @param mixed $lists
* @return array<int, array{type: string, items: array<int, string>}>
*/
function normalizeChoreLists($lists): array {
if (!is_array($lists)) {
return [];
}
$out = [];
foreach ($lists as $block) {
if (!is_array($block)) {
continue;
}
$type = isset($block['type']) ? (string) $block['type'] : 'checkbox';
if (!in_array($type, ['ordered', 'unordered', 'checkbox'], true)) {
$type = 'checkbox';
}
$items = $block['items'] ?? [];
if (!is_array($items)) {
$items = [];
}
$clean = [];
foreach ($items as $it) {
$s = trim((string) $it);
if ($s !== '') {
$clean[] = $s;
}
}
$out[] = ['type' => $type, 'items' => $clean];
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $chores
* @return array<int, array<string, mixed>>
*/
function migrateAllChores(array $chores, array $people): array {
$out = [];
foreach ($chores as $c) {
$out[] = migrateLegacyChoreRow($c, $people);
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $chores
*/
function findChoreById(array $chores, string $id): ?array {
foreach ($chores as $c) {
if (($c['id'] ?? '') === $id) {
return $c;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $chores
*/
function findChoreIndexById(array $chores, string $id): ?int {
foreach ($chores as $i => $c) {
if (($c['id'] ?? '') === $id) {
return $i;
}
}
return null;
}
/**
* @param array<int, string> $assigneeIds
* @param array<int, array<string, mixed>> $people
*/
function choreAssigneeIdsValid(array $assigneeIds, array $people): bool {
$set = [];
foreach ($people as $p) {
if (!empty($p['id'])) {
$set[(string) $p['id']] = true;
}
}
foreach ($assigneeIds as $id) {
if (!isset($set[$id])) {
return false;
}
}
return true;
}