diff --git a/cypress/e2e/1_feature_tests/5_3_Manage_Beneficiaries.js b/cypress/e2e/1_feature_tests/5_3_Manage_Beneficiaries.js index 3b296964b..37b9f983f 100644 --- a/cypress/e2e/1_feature_tests/5_3_Manage_Beneficiaries.js +++ b/cypress/e2e/1_feature_tests/5_3_Manage_Beneficiaries.js @@ -253,7 +253,17 @@ describe('Manage beneficiaries', () => { clickMergeButton(); verifyBeneficiaryRowLevel(TEST_LASTNAME1,0); verifyBeneficiaryRowLevel(TEST_LASTNAME2,1); - + + // Verify history logs for merge operation. + // TEST_LASTNAME1 is the family head (level 0) - its parent_id does not change, so it must NOT be logged. + cy.getBeneficiaryIdFromRow(TEST_LASTNAME1).then(id1 => { + cy.checkHistoryLogAbsent('people', id1, 'parent_id; merged to family'); + }); + // TEST_LASTNAME2 is a member (level 1) - it was merged into the family, so it must be logged. + cy.getBeneficiaryIdFromRow(TEST_LASTNAME2).then(id2 => { + cy.checkHistoryLog('people', id2, 'parent_id; merged to family'); + }); + //cleanup fullDeleteOfMergedUsers(); }); @@ -266,6 +276,12 @@ describe('Manage beneficiaries', () => { clickDetachButton(); verifyBeneficiaryRowLevel(TEST_LASTNAME1,0); verifyBeneficiaryRowLevel(TEST_LASTNAME2,0); + + // Verify history log for detach operation + cy.getBeneficiaryIdFromRow(TEST_LASTNAME2).then(id2 => { + cy.checkHistoryLog('people', id2, 'parent_id; detached from family'); + }); + //cleanup fullDeleteTestedBeneficiaries([TEST_FIRSTNAME1,TEST_FIRSTNAME2]); }); diff --git a/cypress/support/database.js b/cypress/support/database.js index 5dd5b1b4b..273fb10a9 100644 --- a/cypress/support/database.js +++ b/cypress/support/database.js @@ -27,3 +27,46 @@ Cypress.Commands.add("testauth0user", (email) => { expect(response.body).to.contain("true"); }); }); + +Cypress.Commands.add("checkHistoryLog", (tablename, recordId, expectedChange) => { + cy.request({ + method: "POST", + url: "/ajax.php?file=testhistorycheck", + body: { + tablename: tablename, + record_id: recordId, + expected_change: expectedChange + }, + form: true + }).then(response => { + expect(response.status).to.eq(200); + const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; + expect(body.found).to.eq(true); + expect(body.count).to.be.greaterThan(0); + }); +}); + +Cypress.Commands.add("checkHistoryLogAbsent", (tablename, recordId, expectedChange) => { + cy.request({ + method: "POST", + url: "/ajax.php?file=testhistorycheck", + body: { + tablename: tablename, + record_id: recordId, + expected_change: expectedChange + }, + form: true + }).then(response => { + expect(response.status).to.eq(200); + const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; + expect(body.found).to.eq(false); + }); +}); + +Cypress.Commands.add("getBeneficiaryIdFromRow", (lastname) => { + return cy.getRowWithText(lastname).then($row => { + const id = $row.closest('tr').attr('data-id'); + expect(id).to.not.be.undefined; + return parseInt(id); + }); +}); diff --git a/include/people.php b/include/people.php index 6a82a8d91..4328c0494 100644 --- a/include/people.php +++ b/include/people.php @@ -440,6 +440,9 @@ function () use ($cmsmain, $data) { } } }); + // Only log the members whose parent_id actually changed, not the family head itself + $mergedIds = array_values(array_filter($ids, fn ($id) => $id != $oldest)); + simpleBulkSaveChangeHistory('people', $mergedIds, 'parent_id; merged to family', null, [], ['int' => $oldest]); $success = true; $message = 'The merge has be successfully applied'; $redirect = true; @@ -450,9 +453,13 @@ function () use ($cmsmain, $data) { case 'detach': $ids = explode(',', (string) $_POST['ids']); + $parentIdsByBeneficiary = []; foreach ($ids as $key => $value) { - if (!db_value('SELECT parent_id FROM people WHERE id = :id', ['id' => $value])) { + $parentId = db_value('SELECT parent_id FROM people WHERE id = :id', ['id' => $value]); + if (!$parentId) { $containsmembers = true; + } else { + $parentIdsByBeneficiary[$value] = $parentId; } } if ($containsmembers) { @@ -465,6 +472,14 @@ function () use ($cmsmain, $data) { db_query('UPDATE people SET parent_id = NULL WHERE id = :id', ['id' => $id]); } }); + // Group beneficiaries by their old parent_id for history logging + $beneficiariesByParent = []; + foreach ($parentIdsByBeneficiary as $beneficiaryId => $parentId) { + $beneficiariesByParent[$parentId][] = $beneficiaryId; + } + foreach ($beneficiariesByParent as $parentId => $beneficiaryIds) { + simpleBulkSaveChangeHistory('people', $beneficiaryIds, 'parent_id; detached from family', null, ['int' => $parentId], []); + } $redirect = true; $success = true; $message = ($success) ? 'Selected people have been detached' : 'Something went wrong'; @@ -483,7 +498,30 @@ function () use ($cmsmain, $data) { $ids = json_decode((string) $_POST['ids']); // list($success, $message, $redirect, $aftermove) = listMove($table, $ids, true, 'correctdrops'); // Refactored list move method to use a transaction block and bulk insert for the correctdrops method - [$success, $message, $redirect, $aftermove] = listBulkMove($table, $ids, true, 'bulkcorrectdrops', true); + [$success, $message, $redirect, $aftermove, $parentChanges] = listBulkMove($table, $ids, true, 'bulkcorrectdrops', true); + + // Log history for drag & drop family operations + if (!empty($parentChanges)) { + $addedToFamilyByParent = []; + $removedFromFamilyByParent = []; + + foreach ($parentChanges as $change) { + if (is_null($change['old_parent_id']) && !is_null($change['new_parent_id'])) { + // Added to family - group by new parent + $addedToFamilyByParent[$change['new_parent_id']][] = $change['id']; + } elseif (!is_null($change['old_parent_id']) && is_null($change['new_parent_id'])) { + // Removed from family - group by old parent + $removedFromFamilyByParent[$change['old_parent_id']][] = $change['id']; + } + } + + foreach ($addedToFamilyByParent as $parentId => $beneficiaryIds) { + simpleBulkSaveChangeHistory('people', $beneficiaryIds, 'parent_id; added to family via drag & drop', null, [], ['int' => $parentId]); + } + foreach ($removedFromFamilyByParent as $parentId => $beneficiaryIds) { + simpleBulkSaveChangeHistory('people', $beneficiaryIds, 'parent_id; removed from family via drag & drop', null, ['int' => $parentId], []); + } + } break; diff --git a/library/ajax/testhistorycheck.php b/library/ajax/testhistorycheck.php new file mode 100644 index 000000000..8dbc6f285 --- /dev/null +++ b/library/ajax/testhistorycheck.php @@ -0,0 +1,36 @@ + 'No permission']); +} else { + $recordId = $_POST['record_id']; + $tablename = $_POST['tablename']; + $expectedChange = $_POST['expected_change']; + + // Query history table for matching record + $historyEntries = db_array( + 'SELECT * FROM history WHERE tablename = :tablename AND record_id = :record_id AND changes LIKE :changes ORDER BY changedate DESC', + [ + 'tablename' => $tablename, + 'record_id' => $recordId, + 'changes' => '%'.$expectedChange.'%', + ] + ); + + if (count($historyEntries) > 0) { + echo json_encode(['found' => true, 'count' => count($historyEntries), 'entries' => $historyEntries]); + } else { + echo json_encode(['found' => false, 'count' => 0]); + } +} diff --git a/library/lib/list.php b/library/lib/list.php index f24ba54c6..3ad18624b 100644 --- a/library/lib/list.php +++ b/library/lib/list.php @@ -55,7 +55,8 @@ function listBulkMove($table, $ids, $regardparent = true, $hook = '', $updatetra $i = 1; $return = ''; - $hookIds = db_transaction(function () use ($ids, $hasParent, $table, $hook, $i, $updatetransactions) { + $parentChanges = []; + $hookIds = db_transaction(function () use ($ids, $hasParent, $table, $hook, $i, $updatetransactions, &$parentChanges) { $hookIds = []; $seq = []; foreach ($ids as $line) { @@ -72,6 +73,8 @@ function listBulkMove($table, $ids, $regardparent = true, $hook = '', $updatetra if ($updatetransactions && null != $new_parent_id) { db_query('UPDATE transactions SET people_id = :parent_id WHERE people_id = :id', ['parent_id' => $new_parent_id, 'id' => $id]); } + // Track parent_id changes for history logging + $parentChanges[] = ['id' => $id, 'old_parent_id' => $old_parent_id, 'new_parent_id' => $new_parent_id]; } } @@ -88,7 +91,7 @@ function listBulkMove($table, $ids, $regardparent = true, $hook = '', $updatetra $aftermove = $hook($hookIds); } - return [true, $return, false, $aftermove]; + return [true, $return, false, $aftermove, $parentChanges]; } function listBulkRealDelete($table, $ids, $now = null) diff --git a/library/lib/tools.php b/library/lib/tools.php index 47705ab24..ec4eecaad 100644 --- a/library/lib/tools.php +++ b/library/lib/tools.php @@ -298,8 +298,9 @@ function simpleSaveChangeHistory($table, $record, $changes, $now = null, $from = db_query('INSERT INTO history (tablename, record_id, changes, user_id, ip, changedate, from_int, from_float, to_int, to_float) VALUES (:table,:id,:change,:user_id,:ip,:now, :from_int, :from_float, :to_int, :to_float)', ['table' => $table, 'id' => $record, 'change' => $changes, 'user_id' => $_SESSION['user']['id'], 'ip' => $_SERVER['REMOTE_ADDR'], 'now' => $now, 'from_int' => $from['int'], 'from_float' => $from['float'], 'to_int' => $to['int'], 'to_float' => $to['float']]); } -function simpleBulkSaveChangeHistory($table, $records, $changes, $now = null) +function simpleBulkSaveChangeHistory($table, $records, $changes, $now = null, $from = [], $to = []) { + // from and to variable must be arrays with entry 'int' or 'float' if (!db_tableexists('history')) { return; } @@ -310,14 +311,24 @@ function simpleBulkSaveChangeHistory($table, $records, $changes, $now = null) $params = ['now' => $now]; if (is_iterable($records)) { for ($i = 0; $i < sizeof($records); ++$i) { - $query .= "(:table{$i},:id{$i},:change{$i},:user_id{$i},:ip{$i},:now)"; - $params = array_merge($params, ['table'.$i => $table, 'id'.$i => $records[$i], 'change'.$i => $changes, 'user_id'.$i => $_SESSION['user']['id'], 'ip'.$i => $_SERVER['REMOTE_ADDR']]); + $query .= "(:table{$i},:id{$i},:change{$i},:user_id{$i},:ip{$i},:now, :from_int{$i}, :from_float{$i}, :to_int{$i}, :to_float{$i})"; + $params = array_merge($params, ['table'.$i => $table, 'id'.$i => $records[$i], 'change'.$i => $changes, 'user_id'.$i => $_SESSION['user']['id'], 'ip'.$i => $_SERVER['REMOTE_ADDR'], 'from_int'.$i => $from['int'] ?? null, 'from_float'.$i => $from['float'] ?? null, 'to_int'.$i => $to['int'] ?? null, 'to_float'.$i => $to['float'] ?? null]); if ($i !== sizeof($records) - 1) { $query .= ','; } } } if (strlen($query) > 0) { - db_query("INSERT INTO history (tablename, record_id, changes, user_id, ip, changedate) VALUES {$query}", $params); + db_query("INSERT INTO history (tablename, record_id, changes, user_id, ip, changedate, from_int, from_float, to_int, to_float) VALUES {$query}", $params); + + // Update modified timestamp for all affected records + if (db_fieldexists($table, 'modified')) { + $idPlaceholders = implode(',', array_map(fn ($i) => ":id{$i}", array_keys($records))); + $updateParams = ['user' => $_SESSION['user']['id'], 'now' => $now]; + foreach ($records as $i => $id) { + $updateParams["id{$i}"] = $id; + } + db_query("UPDATE {$table} SET modified = :now, modified_by = :user WHERE id IN ({$idPlaceholders})", $updateParams); + } } }