diff --git a/api/chore_nfc_rotate.php b/api/chore_nfc_rotate.php
new file mode 100644
index 0000000..1c328d8
--- /dev/null
+++ b/api/chore_nfc_rotate.php
@@ -0,0 +1,72 @@
+ false, 'error' => 'Method not allowed'], 405);
+}
+
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
+$actor = requireActivePerson($people);
+if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
+ sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage NFC'], 403);
+}
+
+$body = readJsonBody();
+$id = isset($body['id']) ? trim((string) $body['id']) : '';
+if ($id === '') {
+ sendJson(['success' => false, 'error' => 'id is required'], 400);
+}
+
+$rawChores = normalizeChoresList(readJsonFile('chores.json'));
+$chores = migrateAllChores($rawChores, $people);
+$idx = findChoreIndexById($chores, $id);
+if ($idx === null) {
+ sendJson(['success' => false, 'error' => 'Chore not found'], 404);
+}
+
+$choreToken = generateOpaqueToken();
+$chore = $chores[$idx];
+$nfcMeta = normalizeChoreNfcMeta($chore['nfc'] ?? null);
+$nfcMeta['token_hash'] = hashOpaqueToken($choreToken);
+$nfcMeta['enabled'] = true;
+$nfcMeta['created_at'] = gmdate('c');
+$chore['nfc'] = $nfcMeta;
+$chores[$idx] = migrateLegacyChoreRow($chore, $people);
+
+$updatedPeople = $people;
+$links = [];
+$familySettings = loadFamilySettings();
+$base = familyHubAppUrl($familySettings) . '/api/chore_submit_nfc.php';
+
+foreach ($updatedPeople as $pi => $person) {
+ $personId = (string) ($person['id'] ?? '');
+ if ($personId === '') {
+ continue;
+ }
+ $personName = (string) ($person['name'] ?? $personId);
+ $personToken = generateOpaqueToken();
+ $updatedPeople[$pi]['nfc_submit_token_hash'] = hashOpaqueToken($personToken);
+ $updatedPeople[$pi]['nfc_submit_token_updated_at'] = gmdate('c');
+ $links[] = [
+ 'person_id' => $personId,
+ 'person_name' => $personName,
+ 'url' => $base . '?id=' . rawurlencode($id) . '&token=' . rawurlencode($choreToken) . '&person_token=' . rawurlencode($personToken),
+ ];
+}
+
+if (!writeJsonFile('people.json', $updatedPeople)) {
+ sendJson(['success' => false, 'error' => 'Failed to save person NFC tokens'], 500);
+}
+if (!writeJsonFile('chores.json', $chores)) {
+ sendJson(['success' => false, 'error' => 'Failed to save chore NFC token'], 500);
+}
+
+sendJson([
+ 'success' => true,
+ 'enabled' => true,
+ 'links' => $links,
+ 'nfc' => $nfcMeta,
+]);
diff --git a/api/chore_nfc_toggle.php b/api/chore_nfc_toggle.php
new file mode 100644
index 0000000..71825ff
--- /dev/null
+++ b/api/chore_nfc_toggle.php
@@ -0,0 +1,43 @@
+ false, 'error' => 'Method not allowed'], 405);
+}
+
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
+$actor = requireActivePerson($people);
+if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
+ sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage NFC'], 403);
+}
+
+$body = readJsonBody();
+$id = isset($body['id']) ? trim((string) $body['id']) : '';
+$enabled = !empty($body['enabled']);
+if ($id === '') {
+ sendJson(['success' => false, 'error' => 'id is required'], 400);
+}
+
+$rawChores = normalizeChoresList(readJsonFile('chores.json'));
+$chores = migrateAllChores($rawChores, $people);
+$idx = findChoreIndexById($chores, $id);
+if ($idx === null) {
+ sendJson(['success' => false, 'error' => 'Chore not found'], 404);
+}
+
+$chore = $chores[$idx];
+$nfcMeta = normalizeChoreNfcMeta($chore['nfc'] ?? null);
+if ((string) ($nfcMeta['token_hash'] ?? '') === '' && $enabled) {
+ sendJson(['success' => false, 'error' => 'Generate an NFC token first'], 400);
+}
+$nfcMeta['enabled'] = $enabled;
+$chore['nfc'] = $nfcMeta;
+$chores[$idx] = migrateLegacyChoreRow($chore, $people);
+
+if (!writeJsonFile('chores.json', $chores)) {
+ sendJson(['success' => false, 'error' => 'Failed to save chore'], 500);
+}
+
+sendJson(['success' => true, 'enabled' => $enabled, 'nfc' => $nfcMeta]);
diff --git a/api/chore_review.php b/api/chore_review.php
index f4fc6cd..bd02521 100644
--- a/api/chore_review.php
+++ b/api/chore_review.php
@@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can review chores'], 403);
@@ -46,13 +46,10 @@ if ($decision === 'reject') {
sendJson(['success' => true]);
}
-$assignees = $row['assignee_ids'] ?? [];
-if (!is_array($assignees)) {
- $assignees = [];
-}
+$submittedBy = trim((string) ($pending['submitted_by'] ?? ''));
$value = (float) ($row['value'] ?? 0);
-$n = count($assignees);
-$share = $n > 0 ? round($value / $n, 2) : 0.0;
+$creditedEach = 0.0;
+$creditedRecipients = 0;
$row['pending_submission'] = null;
@@ -73,15 +70,33 @@ if (!writeJsonFile('chores.json', $chores)) {
foreach ($people as $pi => $p) {
$pid = (string) ($p['id'] ?? '');
- if ($pid === '' || !in_array($pid, $assignees, true)) {
+ if ($pid === '') {
continue;
}
+ if ($submittedBy !== '') {
+ if ($pid !== $submittedBy) {
+ continue;
+ }
+ $creditedEach = $value;
+ } else {
+ $assignees = $row['assignee_ids'] ?? [];
+ if (!is_array($assignees) || !in_array($pid, $assignees, true)) {
+ continue;
+ }
+ $n = count($assignees);
+ $creditedEach = $n > 0 ? round($value / $n, 2) : 0.0;
+ }
$bal = $p['currency_balance'] ?? 0;
- $people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $share;
+ $people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $creditedEach;
+ $creditedRecipients++;
}
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people balances'], 500);
}
-sendJson(['success' => true, 'credited_each' => $share]);
+sendJson([
+ 'success' => true,
+ 'credited_each' => $creditedEach,
+ 'credited_recipients' => $creditedRecipients,
+]);
diff --git a/api/chore_save.php b/api/chore_save.php
index 68f28cd..8ca273e 100644
--- a/api/chore_save.php
+++ b/api/chore_save.php
@@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
$body = readJsonBody();
@@ -37,6 +37,8 @@ if ($id !== '') {
'author_id' => (string) ($actor['id'] ?? ''),
'pending_submission' => null,
'status' => 'active',
+ 'anyone_can_complete' => false,
+ 'nfc' => normalizeChoreNfcMeta(null),
];
$idx = null;
}
@@ -68,6 +70,7 @@ $row['description'] = isset($body['description']) ? trim((string) $body['descrip
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
$row['assignee_ids'] = $assigneeIdsClean;
+$row['anyone_can_complete'] = !empty($body['anyone_can_complete']);
$val = isset($body['value']) ? $body['value'] : 0;
$row['value'] = is_numeric($val) ? max(0.0, (float) $val) : 0.0;
diff --git a/api/chore_submit.php b/api/chore_submit.php
index 7fc17b0..4b8b2f0 100644
--- a/api/chore_submit.php
+++ b/api/chore_submit.php
@@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
$actorId = (string) ($actor['id'] ?? '');
if ($actorId === '') {
@@ -34,7 +34,7 @@ if (($row['status'] ?? '') !== 'active') {
}
$assignees = $row['assignee_ids'] ?? [];
-if (!is_array($assignees) || !in_array($actorId, $assignees, true)) {
+if (empty($row['anyone_can_complete']) && (!is_array($assignees) || !in_array($actorId, $assignees, true))) {
sendJson(['success' => false, 'error' => 'Only assignees can mark this chore complete'], 403);
}
diff --git a/api/chore_submit_nfc.php b/api/chore_submit_nfc.php
new file mode 100644
index 0000000..3f03d87
--- /dev/null
+++ b/api/chore_submit_nfc.php
@@ -0,0 +1,123 @@
+';
+ }
+ echo '
';
+ echo '' . $safeTitle . '' . $meta;
+ echo '';
+ echo '';
+ echo '
';
+ echo '';
+ echo '
' . $safeMessage . '
';
+ if ($redirectUrl !== '' && !$autoRedirect) {
+ echo '
Open Family Hub';
+ }
+ echo '
';
+ exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
+ http_response_code(405);
+ renderNfcResultPage('Method not allowed', 'This NFC URL only supports GET requests.', false);
+}
+
+$familySettings = loadFamilySettings();
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
+$rawChores = normalizeChoresList(readJsonFile('chores.json'));
+$chores = migrateAllChores($rawChores, $people);
+
+$id = isset($_GET['id']) ? trim((string) $_GET['id']) : '';
+$token = isset($_GET['token']) ? trim((string) $_GET['token']) : '';
+$personToken = isset($_GET['person_token']) ? trim((string) $_GET['person_token']) : '';
+$hubUrl = familyHubAppUrl($familySettings);
+$redirectUrl = $hubUrl . '/?tab=chores';
+$showConfirmation = !empty($familySettings['nfc_show_confirmation']);
+
+if ($id === '' || $token === '' || $personToken === '') {
+ http_response_code(400);
+ renderNfcResultPage('Invalid NFC link', 'Required token information is missing from this NFC URL.', false, $redirectUrl, false);
+}
+
+$personMatch = findPersonBySubmitToken($people, $personToken);
+if ($personMatch === null) {
+ http_response_code(403);
+ renderNfcResultPage('Invalid submitter token', 'This NFC submitter token is not valid.', false, $redirectUrl, false);
+}
+
+$submitter = $personMatch['person'];
+$submitterId = (string) ($submitter['id'] ?? '');
+$submitterName = (string) ($submitter['name'] ?? 'Unknown');
+
+$idx = findChoreIndexById($chores, $id);
+if ($idx === null) {
+ http_response_code(404);
+ renderNfcResultPage('Chore not found', 'This chore no longer exists.', false, $redirectUrl, false);
+}
+
+$row = $chores[$idx];
+if (($row['status'] ?? '') !== 'active') {
+ http_response_code(400);
+ renderNfcResultPage('Chore inactive', 'This chore is not currently active.', false, $redirectUrl, false);
+}
+
+$nfcMeta = normalizeChoreNfcMeta($row['nfc'] ?? null);
+if (empty($nfcMeta['enabled']) || !validateTokenHash($token, (string) ($nfcMeta['token_hash'] ?? ''))) {
+ http_response_code(403);
+ renderNfcResultPage('Invalid chore token', 'This NFC chore token is invalid or disabled.', false, $redirectUrl, false);
+}
+
+if (!empty($row['pending_submission']) && is_array($row['pending_submission'])) {
+ http_response_code(400);
+ renderNfcResultPage('Already pending review', 'This chore is already waiting for Head of household approval.', false, $redirectUrl, false);
+}
+
+$anyoneCanComplete = !empty($row['anyone_can_complete']);
+$assignees = $row['assignee_ids'] ?? [];
+if (!$anyoneCanComplete) {
+ if (!is_array($assignees) || !in_array($submitterId, $assignees, true)) {
+ http_response_code(403);
+ renderNfcResultPage('Not assigned', 'Only assigned family members can submit this chore.', false, $redirectUrl, false);
+ }
+}
+
+$cooldownSeconds = (int) ($familySettings['nfc_scan_cooldown_seconds'] ?? 0);
+if ($cooldownSeconds > 0) {
+ $lastUsedAt = trim((string) ($nfcMeta['last_used_at'] ?? ''));
+ if ($lastUsedAt !== '') {
+ $lastTs = strtotime($lastUsedAt);
+ if ($lastTs !== false && (time() - $lastTs) < $cooldownSeconds) {
+ http_response_code(429);
+ renderNfcResultPage('Please wait', 'This tag was just scanned. Try again in a few moments.', false, $redirectUrl, false);
+ }
+ }
+}
+
+$row['pending_submission'] = [
+ 'submitted_at' => gmdate('c'),
+ 'submitted_by' => $submitterId,
+ 'note' => 'Submitted via NFC by ' . $submitterName,
+];
+$nfcMeta['last_used_at'] = gmdate('c');
+$nfcMeta['last_used_ip'] = requestClientAddress();
+$row['nfc'] = $nfcMeta;
+$chores[$idx] = migrateLegacyChoreRow($row, $people);
+
+if (!writeJsonFile('chores.json', $chores)) {
+ http_response_code(500);
+ renderNfcResultPage('Save failed', 'Could not save this submission. Please try again.', false, $redirectUrl, false);
+}
+
+renderNfcResultPage('Submitted for approval', 'Submitted as ' . $submitterName . '. A Head of household will approve before credit is applied.', true, $redirectUrl, !$showConfirmation);
diff --git a/api/family_nfc_admin.php b/api/family_nfc_admin.php
new file mode 100644
index 0000000..e91ff4b
--- /dev/null
+++ b/api/family_nfc_admin.php
@@ -0,0 +1,58 @@
+ false, 'error' => 'Method not allowed'], 405);
+}
+
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
+$actor = requireActivePerson($people);
+if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
+ sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage NFC'], 403);
+}
+
+$body = readJsonBody();
+$action = isset($body['action']) ? trim((string) $body['action']) : '';
+
+if (!in_array($action, ['disable_all_chore_nfc', 'rotate_all_person_tokens'], true)) {
+ sendJson(['success' => false, 'error' => 'Invalid action'], 400);
+}
+
+if ($action === 'disable_all_chore_nfc') {
+ $rawChores = normalizeChoresList(readJsonFile('chores.json'));
+ $chores = migrateAllChores($rawChores, $people);
+ foreach ($chores as $i => $chore) {
+ $meta = normalizeChoreNfcMeta($chore['nfc'] ?? null);
+ $meta['enabled'] = false;
+ $chore['nfc'] = $meta;
+ $chores[$i] = migrateLegacyChoreRow($chore, $people);
+ }
+ if (!writeJsonFile('chores.json', $chores)) {
+ sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
+ }
+ sendJson(['success' => true, 'action' => $action]);
+}
+
+$tokens = [];
+foreach ($people as $i => $person) {
+ $personId = (string) ($person['id'] ?? '');
+ if ($personId === '') {
+ continue;
+ }
+ $token = generateOpaqueToken();
+ $people[$i]['nfc_submit_token_hash'] = hashOpaqueToken($token);
+ $people[$i]['nfc_submit_token_updated_at'] = gmdate('c');
+ $tokens[] = [
+ 'person_id' => $personId,
+ 'person_name' => (string) ($person['name'] ?? $personId),
+ 'person_token' => $token,
+ ];
+}
+
+if (!writeJsonFile('people.json', $people)) {
+ sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
+}
+
+sendJson(['success' => true, 'action' => $action, 'tokens' => $tokens]);
diff --git a/api/family_settings_save.php b/api/family_settings_save.php
index dac3ff6..a07c9f4 100644
--- a/api/family_settings_save.php
+++ b/api/family_settings_save.php
@@ -50,6 +50,23 @@ if (isset($body['week_starts_on'])) {
}
$merged['week_starts_on'] = $w;
}
+if (array_key_exists('nfc_base_url', $body)) {
+ $baseUrl = trim((string) $body['nfc_base_url']);
+ if ($baseUrl !== '' && !preg_match('#^https?://#i', $baseUrl)) {
+ sendJson(['success' => false, 'error' => 'nfc_base_url must start with http:// or https://'], 400);
+ }
+ $merged['nfc_base_url'] = rtrim($baseUrl, '/');
+}
+if (array_key_exists('nfc_show_confirmation', $body)) {
+ $merged['nfc_show_confirmation'] = !empty($body['nfc_show_confirmation']);
+}
+if (array_key_exists('nfc_scan_cooldown_seconds', $body)) {
+ $cooldown = (int) $body['nfc_scan_cooldown_seconds'];
+ if ($cooldown < 0 || $cooldown > 600) {
+ sendJson(['success' => false, 'error' => 'nfc_scan_cooldown_seconds must be 0-600'], 400);
+ }
+ $merged['nfc_scan_cooldown_seconds'] = $cooldown;
+}
$merged = normalizeLoadedFamilySettings($merged);
diff --git a/api/people_create.php b/api/people_create.php
index 242eee5..eb1bd86 100644
--- a/api/people_create.php
+++ b/api/people_create.php
@@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
if (count($people) === 0) {
sendJson(['success' => false, 'error' => 'Use first-time setup to create the initial Head of Household'], 400);
}
@@ -58,6 +58,8 @@ $newPerson = [
'birthday' => $birthday,
'favoriteColor' => $favoriteColor,
'currency_balance' => 0,
+ 'nfc_submit_token_hash' => '',
+ 'nfc_submit_token_updated_at' => '',
'created_at' => gmdate('c'),
];
diff --git a/api/people_create_first.php b/api/people_create_first.php
index dbe5a84..38813c5 100644
--- a/api/people_create_first.php
+++ b/api/people_create_first.php
@@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
if (count($people) > 0) {
sendJson(['success' => false, 'error' => 'People already exist; use signed-in Head of Household'], 403);
}
@@ -46,6 +46,8 @@ $newPerson = [
'birthday' => $birthday,
'favoriteColor' => $favoriteColor,
'currency_balance' => 0,
+ 'nfc_submit_token_hash' => '',
+ 'nfc_submit_token_updated_at' => '',
'created_at' => gmdate('c'),
];
diff --git a/api/people_delete.php b/api/people_delete.php
index 243d6f9..49d27e4 100644
--- a/api/people_delete.php
+++ b/api/people_delete.php
@@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
assertHoHCanManagePeople($people);
$body = readJsonBody();
diff --git a/api/people_reset_pin.php b/api/people_reset_pin.php
index b55187a..d7bb6d5 100644
--- a/api/people_reset_pin.php
+++ b/api/people_reset_pin.php
@@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
assertHoHCanManagePeople($people);
$body = readJsonBody();
diff --git a/api/people_update.php b/api/people_update.php
index 788e2f3..6a994fa 100644
--- a/api/people_update.php
+++ b/api/people_update.php
@@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
-$people = normalizePeopleList(readJsonFile('people.json'));
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
assertHoHCanManagePeople($people);
$body = readJsonBody();
diff --git a/api/person_nfc_token_rotate.php b/api/person_nfc_token_rotate.php
new file mode 100644
index 0000000..2e15341
--- /dev/null
+++ b/api/person_nfc_token_rotate.php
@@ -0,0 +1,46 @@
+ false, 'error' => 'Method not allowed'], 405);
+}
+
+$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
+$actor = requireActivePerson($people);
+if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
+ sendJson(['success' => false, 'error' => 'Only a verified Head of household can rotate NFC person tokens'], 403);
+}
+
+$body = readJsonBody();
+$personId = isset($body['person_id']) ? trim((string) $body['person_id']) : '';
+if ($personId === '') {
+ sendJson(['success' => false, 'error' => 'person_id is required'], 400);
+}
+
+$match = null;
+foreach ($people as $i => $person) {
+ if (($person['id'] ?? '') === $personId) {
+ $match = $i;
+ break;
+ }
+}
+if ($match === null) {
+ sendJson(['success' => false, 'error' => 'Person not found'], 404);
+}
+
+$token = generateOpaqueToken();
+$people[$match]['nfc_submit_token_hash'] = hashOpaqueToken($token);
+$people[$match]['nfc_submit_token_updated_at'] = gmdate('c');
+
+if (!writeJsonFile('people.json', $people)) {
+ sendJson(['success' => false, 'error' => 'Failed to save person token'], 500);
+}
+
+sendJson([
+ 'success' => true,
+ 'person_id' => $personId,
+ 'person_name' => (string) ($people[$match]['name'] ?? $personId),
+ 'person_token' => $token,
+]);
diff --git a/assets/js/main.js b/assets/js/main.js
index f840515..35d3694 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -36,6 +36,15 @@
$el.text(message);
}
+ function escapeHtml(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
var pendingPersonId = null;
var pinModal = null;
@@ -179,7 +188,10 @@
currency_symbol: $('#currency_symbol').val(),
currency_name: $('#currency_name').val(),
currency_permanence: $('#currency_permanence').val(),
- week_starts_on: parseInt($('#week_starts_on').val(), 10)
+ week_starts_on: parseInt($('#week_starts_on').val(), 10),
+ nfc_base_url: ($('#nfc_base_url').val() || '').trim(),
+ nfc_show_confirmation: $('#nfc_show_confirmation').is(':checked'),
+ nfc_scan_cooldown_seconds: parseInt($('#nfc_scan_cooldown_seconds').val(), 10) || 0
};
postJson('/family_settings_save.php', payload)
.then(function () {
@@ -191,6 +203,36 @@
$('#familySettingsFeedback').removeClass('d-none');
});
});
+
+ $('#btnDisableAllChoreNfc').on('click', function () {
+ if (!window.confirm('Disable NFC links for all chores?')) {
+ return;
+ }
+ postJson('/family_nfc_admin.php', { action: 'disable_all_chore_nfc' })
+ .then(function () {
+ showAlert($('#familySettingsFeedback'), 'warning', 'All chore NFC links disabled.');
+ $('#familySettingsFeedback').removeClass('d-none');
+ })
+ .catch(function (err) {
+ showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not disable NFC links');
+ $('#familySettingsFeedback').removeClass('d-none');
+ });
+ });
+
+ $('#btnRotateAllPersonNfcTokens').on('click', function () {
+ if (!window.confirm('Rotate NFC submitter tokens for all people? Existing person-token links will stop working.')) {
+ return;
+ }
+ postJson('/family_nfc_admin.php', { action: 'rotate_all_person_tokens' })
+ .then(function () {
+ showAlert($('#familySettingsFeedback'), 'success', 'All person NFC tokens rotated.');
+ $('#familySettingsFeedback').removeClass('d-none');
+ })
+ .catch(function (err) {
+ showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not rotate tokens');
+ $('#familySettingsFeedback').removeClass('d-none');
+ });
+ });
}
function collectBillDaysFromDom() {
@@ -285,6 +327,7 @@
schedule: $('#chore_schedule').val(),
recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7,
assignee_ids: assignees,
+ anyone_can_complete: $('#chore_anyone_can_complete').is(':checked'),
lists: choreListPayloadFromForm()
};
postJson('/chore_save.php', payload)
@@ -353,6 +396,67 @@
window.alert(err.message || 'Could not reject');
});
});
+
+ $(document).on('click', '.btn-chore-nfc-generate', function () {
+ var id = $(this).data('id');
+ if (!id) {
+ return;
+ }
+ postJson('/chore_nfc_rotate.php', { id: id })
+ .then(function (data) {
+ var links = Array.isArray(data.links) ? data.links : [];
+ var html = 'NFC links (copy per person)
';
+ if (!links.length) {
+ html += 'No links were returned.
';
+ } else {
+ links.forEach(function (row) {
+ html += '';
+ html += '';
+ html += '';
+ html += '';
+ html += '
';
+ });
+ }
+ var $links = $('#chore_nfc_links_' + id);
+ $links.html(html).removeClass('d-none');
+ showAlert($('#chore_nfc_feedback_' + id), 'success', 'Generated new NFC links.');
+ $('#chore_nfc_feedback_' + id).removeClass('d-none');
+ })
+ .catch(function (err) {
+ showAlert($('#chore_nfc_feedback_' + id), 'danger', err.message || 'Could not generate links');
+ $('#chore_nfc_feedback_' + id).removeClass('d-none');
+ });
+ });
+
+ $(document).on('click', '.btn-copy-nfc-link', function () {
+ var $input = $(this).closest('.input-group').find('.chore-nfc-link-input');
+ var val = $input.val() || '';
+ if (!val) {
+ return;
+ }
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(String(val));
+ } else {
+ $input.trigger('focus').trigger('select');
+ document.execCommand('copy');
+ }
+ });
+
+ $(document).on('click', '.btn-chore-nfc-toggle', function () {
+ var id = $(this).data('id');
+ var enable = String($(this).data('enable')) === '1';
+ if (!id) {
+ return;
+ }
+ postJson('/chore_nfc_toggle.php', { id: id, enabled: enable })
+ .then(function () {
+ window.location.reload();
+ })
+ .catch(function (err) {
+ showAlert($('#chore_nfc_feedback_' + id), 'danger', err.message || 'Could not update NFC status');
+ $('#chore_nfc_feedback_' + id).removeClass('d-none');
+ });
+ });
}
function groceryReloadKeepParams() {
@@ -967,6 +1071,21 @@
window.alert(err.message || 'Could not reset PIN');
});
});
+
+ $(document).on('click', '.btn-rotate-person-nfc-token', function () {
+ var id = $(this).data('id');
+ var name = $(this).data('name') || 'this person';
+ if (!id || !window.confirm('Rotate NFC submitter token for ' + name + '? Existing personal NFC links will stop working.')) {
+ return;
+ }
+ postJson('/person_nfc_token_rotate.php', { person_id: id })
+ .then(function () {
+ window.alert('Token rotated for ' + name + '. Generate fresh chore NFC links as needed.');
+ })
+ .catch(function (err) {
+ window.alert(err.message || 'Could not rotate NFC token');
+ });
+ });
}
$(function () {
diff --git a/includes/chore_helpers.php b/includes/chore_helpers.php
index de8e996..db83b37 100644
--- a/includes/chore_helpers.php
+++ b/includes/chore_helpers.php
@@ -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
+ */
+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}>
diff --git a/includes/family_settings.php b/includes/family_settings.php
index 1c092b5..3dd02a2 100644
--- a/includes/family_settings.php
+++ b/includes/family_settings.php
@@ -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").
*/
diff --git a/includes/persona.php b/includes/persona.php
index 8cf6ae9..1a15945 100644
--- a/includes/persona.php
+++ b/includes/persona.php
@@ -97,3 +97,54 @@ function normalizePeopleList($raw): array {
}
return $out;
}
+
+/**
+ * @param array $person
+ * @return array
+ */
+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> $people
+ * @return array>
+ */
+function migrateAllPeople(array $people): array {
+ $out = [];
+ foreach ($people as $person) {
+ if (!is_array($person)) {
+ continue;
+ }
+ $out[] = migrateLegacyPersonRow($person);
+ }
+ return $out;
+}
+
+/**
+ * @param array> $people
+ * @return array{index:int, person:array}|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;
+}
diff --git a/readme.md b/readme.md
index d99303e..a92e68f 100644
--- a/readme.md
+++ b/readme.md
@@ -114,10 +114,63 @@ These are **not** in `.env`; they live in `data/family_settings.json` and are ed
**API:** `GET api/calendar_events.php?from=YYYY-MM-DD&to=YYYY-MM-DD` returns `{ success, events, rangeStart, rangeEnd, today }` for the signed-in profile (same session rules as other APIs). Omit `from`/`to` to use the default two-week window from **today** in the family timezone.
+## NFC chore submission (HoH)
+
+NFC support uses per-chore tokens plus per-person submitter tokens so a scan can submit as a specific person and credit that person after HoH approval.
+
+### Family settings for NFC
+
+In **Family settings → Chore economy**:
+
+- **NFC base URL override** (`nfc_base_url`): optional app base URL used when generating NFC links. Leave blank to auto-detect from the current request.
+- **NFC scan cooldown seconds** (`nfc_scan_cooldown_seconds`): optional soft replay dampener (0-600 seconds).
+- **Show NFC confirmation page after scan** (`nfc_show_confirmation`): when off, NFC scans redirect directly to chores.
+- **Emergency NFC actions**:
+ - Disable all chore NFC links
+ - Rotate all person NFC submitter tokens
+
+### HoH setup checklist (per chore)
+
+1. Open **Chores** as a verified Head of household.
+2. For a chore, optionally enable **Allow anyone to complete this chore**.
+3. Click **Generate NFC links**.
+4. Copy each person-specific URL and write it to that person’s NFC card/tag (NDEF URI).
+5. Label tags physically (name + chore).
+6. Test scan once per person.
+
+### NFC URL format
+
+Generated links use:
+
+`/api/chore_submit_nfc.php?id=&token=&person_token=`
+
+- `token` identifies the chore NFC token.
+- `person_token` identifies who is submitting (credit recipient on approval).
+
+### Completion and credit rules
+
+- **Assignee-only chore** (`anyone_can_complete = false`): only assignees may submit, even with valid person tokens.
+- **Anyone-can-complete chore** (`anyone_can_complete = true`): any valid person token holder may submit.
+- Credit is always applied only on HoH approval (not at scan time), to the `submitted_by` person on the pending submission.
+
+### Troubleshooting
+
+- **Invalid chore token**: regenerate chore NFC links and rewrite tags.
+- **Invalid submitter token**: rotate that person token and regenerate links.
+- **Already pending review**: wait for HoH approve/reject.
+- **Chore inactive**: reactivate/update chore before scanning again.
+- **Lost/shared card**: rotate token(s) or disable chore NFC immediately.
+
+### Security notes
+
+- NFC links are bearer-style secrets. Anyone with a valid URL can submit as that URL’s person token.
+- Use HTTPS in production and rotate tokens on loss/suspected leakage.
+- Keep HoH approval enabled as the payout gate.
+
## Directory Structure
```
familyHub/
-├── api/ # JSON POST endpoints (chores, groceries, meals, people, settings, expenses, calendar_events, …)
+├── api/ # JSON endpoints (chores, groceries, meals, people, settings, expenses, calendar_events, NFC helpers, …)
├── assets/ # Static assets
│ ├── css/ # CSS files
│ │ └── style.css
diff --git a/tabs/chores.php b/tabs/chores.php
index 3121fe0..026ea02 100644
--- a/tabs/chores.php
+++ b/tabs/chores.php
@@ -206,6 +206,15 @@ if ($editChore) {
+
+
+ >
+
+
+
+
diff --git a/tabs/settings.php b/tabs/settings.php
index 69cf4f1..06101b2 100644
--- a/tabs/settings.php
+++ b/tabs/settings.php
@@ -164,10 +164,37 @@ $permanenceOptions = [
value="= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
= !$canManage ? 'disabled' : '' ?>>
+
+
+ >
+
+
+
+ >
+
+
+
+
+ = !$canManage ? 'disabled' : '' ?>>
+
+
+
+
+
Emergency NFC actions
+
+
+
+
+
@@ -266,6 +293,7 @@ $permanenceOptions = [
Name |
Role |
Balance |
+
NFC submit token |
|
@@ -276,6 +304,9 @@ $permanenceOptions = [
= sanitizeInput($p['role'] ?? '') ?> |
= sanitizeInput((string) ($p['currency_balance'] ?? 0)) ?> = sanitizeInput($familySettings['currency_symbol'] ?? '') ?> |
+
+
+ |
|