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.
This commit is contained in:
2026-03-31 10:28:27 -05:00
parent 9a78c4564e
commit f14de0b7e1
21 changed files with 779 additions and 21 deletions
+52
View File
@@ -4,6 +4,7 @@ require_once __DIR__ . '/persona.php';
const CHORE_SCHEDULE_ONCE = 'once';
const CHORE_SCHEDULE_RECURRING = 'recurring';
const CHORE_NFC_HASH_ALGO = 'sha256';
/**
* @param mixed $raw
@@ -106,9 +107,60 @@ function migrateLegacyChoreRow(array $c, array $people): array {
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>}>
+30
View File
@@ -34,6 +34,9 @@ function familySettingsDefaultsRaw(): array {
'week_starts_on' => 0,
'calendar_two_way_google' => false,
'calendar_bill_days' => [],
'nfc_base_url' => '',
'nfc_show_confirmation' => true,
'nfc_scan_cooldown_seconds' => 0,
];
}
@@ -80,6 +83,11 @@ function normalizeLoadedFamilySettings(array $s): array {
$v = $s['calendar_two_way_google'] ?? false;
$s['calendar_two_way_google'] = $v === true || $v === 1 || $v === '1' || $v === 'true';
$s['calendar_bill_days'] = normalizeCalendarBillDaysRaw($s['calendar_bill_days'] ?? []);
$s['nfc_base_url'] = trim((string) ($s['nfc_base_url'] ?? ''));
$showConfirmation = $s['nfc_show_confirmation'] ?? true;
$s['nfc_show_confirmation'] = $showConfirmation === true || $showConfirmation === 1 || $showConfirmation === '1' || $showConfirmation === 'true';
$cooldown = (int) ($s['nfc_scan_cooldown_seconds'] ?? 0);
$s['nfc_scan_cooldown_seconds'] = max(0, min(600, $cooldown));
return $s;
}
@@ -96,6 +104,28 @@ function loadFamilySettings(): array {
return normalizeLoadedFamilySettings($merged);
}
/**
* Base app URL (without trailing slash) used for externally shared links.
*/
function familyHubAppUrl(array $familySettings): string {
$base = trim((string) ($familySettings['nfc_base_url'] ?? ''));
if ($base !== '') {
return rtrim($base, '/');
}
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$scheme = $https ? 'https' : 'http';
$host = trim((string) ($_SERVER['HTTP_HOST'] ?? 'localhost'));
$script = trim((string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php'));
$dir = rtrim(str_replace('\\', '/', dirname($script)), '/');
if ($dir === '' || $dir === '.') {
$dir = '';
}
if (substr($dir, -4) === '/api') {
$dir = substr($dir, 0, -4);
}
return $scheme . '://' . $host . $dir;
}
/**
* Tab label: symbol + name (e.g. "★ Stars").
*/
+51
View File
@@ -97,3 +97,54 @@ function normalizePeopleList($raw): array {
}
return $out;
}
/**
* @param array<string, mixed> $person
* @return array<string, mixed>
*/
function migrateLegacyPersonRow(array $person): array {
if (!array_key_exists('nfc_submit_token_hash', $person) || !is_string($person['nfc_submit_token_hash'])) {
$person['nfc_submit_token_hash'] = '';
}
if (!array_key_exists('nfc_submit_token_updated_at', $person) || !is_string($person['nfc_submit_token_updated_at'])) {
$person['nfc_submit_token_updated_at'] = '';
}
return $person;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array<int, array<string, mixed>>
*/
function migrateAllPeople(array $people): array {
$out = [];
foreach ($people as $person) {
if (!is_array($person)) {
continue;
}
$out[] = migrateLegacyPersonRow($person);
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array{index:int, person:array<string, mixed>}|null
*/
function findPersonBySubmitToken(array $people, string $token): ?array {
$token = trim($token);
if ($token === '') {
return null;
}
$candidateHash = hash('sha256', $token);
foreach ($people as $index => $person) {
$storedHash = trim((string) ($person['nfc_submit_token_hash'] ?? ''));
if ($storedHash === '') {
continue;
}
if (hash_equals($storedHash, $candidateHash)) {
return ['index' => (int) $index, 'person' => $person];
}
}
return null;
}