From 5ad58bd41c2e1eae452654a68e92d04c8d5be47e Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Wed, 1 Apr 2026 12:21:43 -0400 Subject: [PATCH 01/15] refactor: migrate learner management start date to progress repositories Add set_started_at() to course and lesson progress model interfaces and abstract classes. Migrate edit_date_started() in learner management and the start date display in learners-main to use progress repositories instead of direct comment meta access. Update the sensei_learners_learner_updated filter to pass user_id instead of comment_id. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../add-hpps-learner-management-start-date | 4 ++ .../admin/class-sensei-learner-management.php | 40 ++++++++++++------- includes/admin/class-sensei-learners-main.php | 10 ++++- .../models/class-course-progress-abstract.php | 13 ++++++ .../class-course-progress-interface.php | 11 +++++ .../models/class-lesson-progress-abstract.php | 13 ++++++ .../class-lesson-progress-interface.php | 11 +++++ ...t-class-comments-based-course-progress.php | 12 ++++++ ...est-class-tables-based-course-progress.php | 12 ++++++ ...t-class-comments-based-lesson-progress.php | 12 ++++++ ...est-class-tables-based-lesson-progress.php | 12 ++++++ 11 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 changelog/add-hpps-learner-management-start-date diff --git a/changelog/add-hpps-learner-management-start-date b/changelog/add-hpps-learner-management-start-date new file mode 100644 index 0000000000..6feaf5268a --- /dev/null +++ b/changelog/add-hpps-learner-management-start-date @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Migrate learner management start date editing to use progress repositories instead of direct comment meta access. diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 6a89b11b27..0bc63fa2a1 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -479,20 +479,18 @@ public function edit_date_started() { exit( '' ); } - if ( ! empty( $_POST['data']['comment_id'] ) && is_numeric( $_POST['data']['comment_id'] ) ) { - $comment_id = (int) sanitize_key( $_POST['data']['comment_id'] ); + if ( ! empty( $_POST['data']['user_id'] ) && is_numeric( $_POST['data']['user_id'] ) ) { + $user_id = (int) sanitize_key( $_POST['data']['user_id'] ); } else { exit( '' ); } - $comment = get_comment( $comment_id ); + $post_type = get_post_type( $post_id ); - if ( empty( $comment ) ) { + if ( ! in_array( $post_type, array( 'course', 'lesson' ), true ) ) { exit( '' ); } - $post_type = get_post_type( $post_id ); - if ( 'lesson' === $post_type ) { $can_edit_date = $this->can_user_manage_students( (int) Sensei()->lesson->get_course_id( $post_id ), intval( $post->post_author ) ); } else { @@ -513,20 +511,32 @@ public function edit_date_started() { exit( '' ); } - $date_started = get_comment_meta( $comment_id, 'start', true ); - $expected_date_format = 'Y-m-d H:i:s'; if ( false === strpos( $date_string, ' ' ) ) { $expected_date_format = 'Y-m-d'; } - $date = DateTimeImmutable::createFromFormat( $expected_date_format, $date_string ); + $date = DateTimeImmutable::createFromFormat( $expected_date_format, $date_string, wp_timezone() ); if ( false === $date ) { exit( '' ); } - $formatted_date = gmdate( 'Y-m-d H:i:s', $date->getTimestamp() ); - $updated = (bool) update_comment_meta( $comment_id, 'start', $formatted_date, $date_started ); + if ( 'course' === $post_type ) { + $repository = Sensei()->course_progress_repository_factory->create(); + $progress = $repository->get( $post_id, $user_id ); + } else { + $repository = Sensei()->lesson_progress_repository_factory->create(); + $progress = $repository->get( $post_id, $user_id ); + } + + if ( ! $progress ) { + exit( '' ); + } + + $progress->set_started_at( $date ); + $repository->save( $progress ); + + $formatted_date = $date->format( 'Y-m-d H:i:s' ); /** * Filter sensei_learners_learner_updated @@ -535,12 +545,12 @@ public function edit_date_started() { * * @hook sensei_learners_learner_updated * - * @param {bool} $updated A flag indicating if there was an update in the learner row. - * @param {int} $post_id Lesson or course id. - * @param {int} $comment_id The comment id which tracks the progress of the learner. + * @param {bool} $updated A flag indicating if there was an update in the learner row. + * @param {int} $post_id Lesson or course id. + * @param {int} $user_id The user id of the learner. * @return {bool} False if there were no updates. */ - $updated = apply_filters( 'sensei_learners_learner_updated', $updated, $post_id, $comment_id ); + $updated = apply_filters( 'sensei_learners_learner_updated', true, $post_id, $user_id ); if ( false === $updated ) { exit( '' ); diff --git a/includes/admin/class-sensei-learners-main.php b/includes/admin/class-sensei-learners-main.php index 33c480741c..34bd58f6ff 100644 --- a/includes/admin/class-sensei-learners-main.php +++ b/includes/admin/class-sensei-learners-main.php @@ -536,7 +536,15 @@ protected function get_row_data( $item ) { $actions[] = $edit_start_date_form; } - $date_started = get_comment_meta( $user_activity->comment_ID, 'start', true ); + if ( 'course' === $post_type ) { + $progress_repository = Sensei()->course_progress_repository_factory->create(); + $learner_progress = $progress_repository->get( $post_id, $user_activity->user_id ); + } else { + $progress_repository = Sensei()->lesson_progress_repository_factory->create(); + $learner_progress = $progress_repository->get( $post_id, $user_activity->user_id ); + } + $learner_started_at = $learner_progress ? $learner_progress->get_started_at() : null; + $date_started = $learner_started_at ? $learner_started_at->format( 'Y-m-d H:i:s' ) : ''; $date_input = ''; /** diff --git a/includes/internal/student-progress/course-progress/models/class-course-progress-abstract.php b/includes/internal/student-progress/course-progress/models/class-course-progress-abstract.php index e3b5194d53..60422b1324 100644 --- a/includes/internal/student-progress/course-progress/models/class-course-progress-abstract.php +++ b/includes/internal/student-progress/course-progress/models/class-course-progress-abstract.php @@ -219,6 +219,19 @@ public function get_created_at(): DateTimeInterface { return $this->created_at; } + /** + * Set the course start date. + * + * @internal + * + * @since $$next-version$$ + * + * @param DateTimeInterface $started_at Course start date. + */ + public function set_started_at( DateTimeInterface $started_at ): void { + $this->started_at = $started_at; + } + /** * Set the course progress updated date. * diff --git a/includes/internal/student-progress/course-progress/models/class-course-progress-interface.php b/includes/internal/student-progress/course-progress/models/class-course-progress-interface.php index e31a4fa718..1192abadb2 100644 --- a/includes/internal/student-progress/course-progress/models/class-course-progress-interface.php +++ b/includes/internal/student-progress/course-progress/models/class-course-progress-interface.php @@ -125,6 +125,17 @@ public function get_updated_at(): DateTimeInterface; */ public function get_created_at(): DateTimeInterface; + /** + * Set the course start date. + * + * @internal + * + * @since $$next-version$$ + * + * @param DateTimeInterface $started_at Course start date. + */ + public function set_started_at( DateTimeInterface $started_at ): void; + /** * Set the course progress updated date. * diff --git a/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-abstract.php b/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-abstract.php index c4f491e690..0b377e543b 100644 --- a/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-abstract.php +++ b/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-abstract.php @@ -227,6 +227,19 @@ public function get_updated_at(): DateTimeInterface { return $this->updated_at; } + /** + * Set the lesson start date. + * + * @internal + * + * @since $$next-version$$ + * + * @param DateTimeInterface $started_at The start date. + */ + public function set_started_at( DateTimeInterface $started_at ): void { + $this->started_at = $started_at; + } + /** * Set lesson progress updated date. * diff --git a/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-interface.php b/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-interface.php index 5e7b8d9972..2b916b1a37 100644 --- a/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-interface.php +++ b/includes/internal/student-progress/lesson-progress/models/class-lesson-progress-interface.php @@ -134,6 +134,17 @@ public function get_created_at(): DateTimeInterface; */ public function get_updated_at(): DateTimeInterface; + /** + * Set the lesson start date. + * + * @internal + * + * @since $$next-version$$ + * + * @param DateTimeInterface $started_at The start date. + */ + public function set_started_at( DateTimeInterface $started_at ): void; + /** * Set lesson progress updated date. * diff --git a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php index 326a2f8558..5c6c5df104 100644 --- a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php +++ b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php @@ -50,6 +50,18 @@ public function testGetCompletedAt_WhenCompleteWithCompletedAtCalled_ReturnsSame self::assertSame( $completed_at, $progress->get_completed_at() ); } + public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { + /* Arrange. */ + $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); + $progress = $this->create_progress(); + + /* Act. */ + $progress->set_started_at( $started_at ); + + /* Assert. */ + self::assertSame( $started_at, $progress->get_started_at() ); + } + public function testGetStatus_WhenCompleteCalled_ReturnsMatchingStatus(): void { /* Arrange. */ $progress = $this->create_progress(); diff --git a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php index 4c490b8853..7e2c4700a1 100644 --- a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php +++ b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php @@ -50,6 +50,18 @@ public function testGetCompletedAt_WhenCompleteWithCompletedAtCalled_ReturnsSame self::assertSame( $completed_at, $progress->get_completed_at() ); } + public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { + /* Arrange. */ + $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); + $progress = $this->create_progress(); + + /* Act. */ + $progress->set_started_at( $started_at ); + + /* Assert. */ + self::assertSame( $started_at, $progress->get_started_at() ); + } + public function testGetStatus_WhenCompleteCalled_ReturnsMatchingStatus(): void { /* Arrange. */ $progress = $this->create_progress(); diff --git a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php index fa36b512a6..256d82bb30 100644 --- a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php +++ b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php @@ -210,6 +210,18 @@ public function testGetUpdatedAt_WhenUpdatedAtSet_ReturnsSameUpdatedAt(): void { self::assertSame( '2020-01-01 00:00:04', $actual ); } + public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { + /* Arrange. */ + $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); + $progress = $this->create_progress(); + + /* Act. */ + $progress->set_started_at( $started_at ); + + /* Assert. */ + self::assertSame( $started_at, $progress->get_started_at() ); + } + public function testGetStartedAt_WhenStartWithStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable(); diff --git a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php index f1154a4505..491e082178 100644 --- a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php +++ b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php @@ -146,6 +146,18 @@ public function testGetUpdatedAt_WhenUpdatedAtSet_ReturnsSameUpdatedAt(): void { self::assertSame( '2020-01-01 00:00:04', $actual ); } + public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { + /* Arrange. */ + $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); + $progress = $this->create_progress(); + + /* Act. */ + $progress->set_started_at( $started_at ); + + /* Assert. */ + self::assertSame( $started_at, $progress->get_started_at() ); + } + public function testGetStartedAt_WhenStartWithStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable(); From 6f16c89b97484b722fb20cd0f186945e7514355d Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Wed, 1 Apr 2026 15:01:17 -0400 Subject: [PATCH 02/15] fix: address review comments on learner management start date PR - Convert date to UTC before set_started_at() for tables-based storage - Compute $updated from old vs new started_at comparison - Preserve hook backward compat: keep deprecated comment_id param (0), add user_id as 4th - Use absint() instead of sanitize_key() for user_id - Use wp_date() with wp_timezone() for start date display - Fix equals sign alignment in learners-main Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/class-sensei-learner-management.php | 16 ++++++++++------ includes/admin/class-sensei-learners-main.php | 4 ++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 0bc63fa2a1..b982183811 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -480,7 +480,7 @@ public function edit_date_started() { } if ( ! empty( $_POST['data']['user_id'] ) && is_numeric( $_POST['data']['user_id'] ) ) { - $user_id = (int) sanitize_key( $_POST['data']['user_id'] ); + $user_id = absint( wp_unslash( $_POST['data']['user_id'] ) ); } else { exit( '' ); } @@ -533,9 +533,12 @@ public function edit_date_started() { exit( '' ); } - $progress->set_started_at( $date ); + $utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) ); + $old_started_at = $progress->get_started_at(); + $progress->set_started_at( $utc_date ); $repository->save( $progress ); + $updated = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() ); $formatted_date = $date->format( 'Y-m-d H:i:s' ); /** @@ -545,12 +548,13 @@ public function edit_date_started() { * * @hook sensei_learners_learner_updated * - * @param {bool} $updated A flag indicating if there was an update in the learner row. - * @param {int} $post_id Lesson or course id. - * @param {int} $user_id The user id of the learner. + * @param {bool} $updated A flag indicating if there was an update in the learner row. + * @param {int} $post_id Lesson or course id. + * @param {int} $comment_id Deprecated. Always 0. Previously the comment id tracking learner progress. + * @param {int} $user_id The user id of the learner. Since $$next-version$$. * @return {bool} False if there were no updates. */ - $updated = apply_filters( 'sensei_learners_learner_updated', true, $post_id, $user_id ); + $updated = apply_filters( 'sensei_learners_learner_updated', $updated, $post_id, 0, $user_id ); if ( false === $updated ) { exit( '' ); diff --git a/includes/admin/class-sensei-learners-main.php b/includes/admin/class-sensei-learners-main.php index 34bd58f6ff..f371b63fc4 100644 --- a/includes/admin/class-sensei-learners-main.php +++ b/includes/admin/class-sensei-learners-main.php @@ -544,8 +544,8 @@ protected function get_row_data( $item ) { $learner_progress = $progress_repository->get( $post_id, $user_activity->user_id ); } $learner_started_at = $learner_progress ? $learner_progress->get_started_at() : null; - $date_started = $learner_started_at ? $learner_started_at->format( 'Y-m-d H:i:s' ) : ''; - $date_input = ''; + $date_started = $learner_started_at ? wp_date( 'Y-m-d H:i:s', $learner_started_at->getTimestamp(), wp_timezone() ) : ''; + $date_input = ''; /** * Filter sensei_learners_main_column_data From dbf1adefdcab00eb07db2e40ddca788abbd72a0c Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Wed, 1 Apr 2026 15:14:07 -0400 Subject: [PATCH 03/15] fix: add missing docblocks to progress model test methods Co-Authored-By: Claude Opus 4.6 (1M context) --- .../models/test-class-comments-based-course-progress.php | 3 +++ .../models/test-class-tables-based-course-progress.php | 3 +++ .../models/test-class-comments-based-lesson-progress.php | 3 +++ .../models/test-class-tables-based-lesson-progress.php | 3 +++ 4 files changed, 12 insertions(+) diff --git a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php index 5c6c5df104..655a7be6cb 100644 --- a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php +++ b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php @@ -50,6 +50,9 @@ public function testGetCompletedAt_WhenCompleteWithCompletedAtCalled_ReturnsSame self::assertSame( $completed_at, $progress->get_completed_at() ); } + /** + * Tests that set_started_at correctly updates the started_at value. + */ public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); diff --git a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php index 7e2c4700a1..71d8894ddf 100644 --- a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php +++ b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php @@ -50,6 +50,9 @@ public function testGetCompletedAt_WhenCompleteWithCompletedAtCalled_ReturnsSame self::assertSame( $completed_at, $progress->get_completed_at() ); } + /** + * Tests that set_started_at correctly updates the started_at value. + */ public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); diff --git a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php index 256d82bb30..626664ea7a 100644 --- a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php +++ b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php @@ -210,6 +210,9 @@ public function testGetUpdatedAt_WhenUpdatedAtSet_ReturnsSameUpdatedAt(): void { self::assertSame( '2020-01-01 00:00:04', $actual ); } + /** + * Tests that set_started_at correctly updates the started_at value. + */ public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); diff --git a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php index 491e082178..93471d4801 100644 --- a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php +++ b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php @@ -146,6 +146,9 @@ public function testGetUpdatedAt_WhenUpdatedAtSet_ReturnsSameUpdatedAt(): void { self::assertSame( '2020-01-01 00:00:04', $actual ); } + /** + * Tests that set_started_at correctly updates the started_at value. + */ public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); From fc51a1f321dcb48c7e6fcae0b57ec8bba26c8667 Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Wed, 1 Apr 2026 15:22:28 -0400 Subject: [PATCH 04/15] fix: resolve Psalm type errors in learner management - Split course/lesson repo branches to avoid union type on save() - Guard $post_id against false before passing to repo get() - Cast wp_date() return to string to satisfy esc_attr() type Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/class-sensei-learner-management.php | 23 +++++++++++-------- includes/admin/class-sensei-learners-main.php | 17 ++++++++------ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index b982183811..441630d410 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -521,23 +521,28 @@ public function edit_date_started() { exit( '' ); } + $utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) ); + if ( 'course' === $post_type ) { $repository = Sensei()->course_progress_repository_factory->create(); $progress = $repository->get( $post_id, $user_id ); + if ( ! $progress ) { + exit( '' ); + } + $old_started_at = $progress->get_started_at(); + $progress->set_started_at( $utc_date ); + $repository->save( $progress ); } else { $repository = Sensei()->lesson_progress_repository_factory->create(); $progress = $repository->get( $post_id, $user_id ); + if ( ! $progress ) { + exit( '' ); + } + $old_started_at = $progress->get_started_at(); + $progress->set_started_at( $utc_date ); + $repository->save( $progress ); } - if ( ! $progress ) { - exit( '' ); - } - - $utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) ); - $old_started_at = $progress->get_started_at(); - $progress->set_started_at( $utc_date ); - $repository->save( $progress ); - $updated = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() ); $formatted_date = $date->format( 'Y-m-d H:i:s' ); diff --git a/includes/admin/class-sensei-learners-main.php b/includes/admin/class-sensei-learners-main.php index f371b63fc4..84c9aed31c 100644 --- a/includes/admin/class-sensei-learners-main.php +++ b/includes/admin/class-sensei-learners-main.php @@ -536,15 +536,18 @@ protected function get_row_data( $item ) { $actions[] = $edit_start_date_form; } - if ( 'course' === $post_type ) { - $progress_repository = Sensei()->course_progress_repository_factory->create(); - $learner_progress = $progress_repository->get( $post_id, $user_activity->user_id ); - } else { - $progress_repository = Sensei()->lesson_progress_repository_factory->create(); - $learner_progress = $progress_repository->get( $post_id, $user_activity->user_id ); + $learner_progress = null; + if ( $post_id ) { + if ( 'course' === $post_type ) { + $progress_repository = Sensei()->course_progress_repository_factory->create(); + $learner_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); + } else { + $progress_repository = Sensei()->lesson_progress_repository_factory->create(); + $learner_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); + } } $learner_started_at = $learner_progress ? $learner_progress->get_started_at() : null; - $date_started = $learner_started_at ? wp_date( 'Y-m-d H:i:s', $learner_started_at->getTimestamp(), wp_timezone() ) : ''; + $date_started = $learner_started_at ? (string) wp_date( 'Y-m-d H:i:s', $learner_started_at->getTimestamp(), wp_timezone() ) : ''; $date_input = ''; /** From 0d5fda1e76a4e4bf59cb5b72b41805f8597602fc Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Wed, 15 Apr 2026 15:28:20 -0400 Subject: [PATCH 05/15] fix: deprecate sensei_learners_learner_updated filter with no replacement The filter's $comment_id parameter is a storage implementation detail that no longer applies across both storage backends. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/class-sensei-learner-management.php | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 441630d410..ebfc3f0ac9 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -549,21 +549,14 @@ public function edit_date_started() { /** * Filter sensei_learners_learner_updated * - * This filter should return false if there was no update in the learner row. + * @deprecated $$next-version$$ No replacement — this filter is no longer supported. * - * @hook sensei_learners_learner_updated - * - * @param {bool} $updated A flag indicating if there was an update in the learner row. - * @param {int} $post_id Lesson or course id. - * @param {int} $comment_id Deprecated. Always 0. Previously the comment id tracking learner progress. - * @param {int} $user_id The user id of the learner. Since $$next-version$$. + * @param {bool} $updated A flag indicating if there was an update in the learner row. + * @param {int} $post_id Lesson or course id. + * @param {int} $comment_id The comment id which tracks the progress of the learner. * @return {bool} False if there were no updates. */ - $updated = apply_filters( 'sensei_learners_learner_updated', $updated, $post_id, 0, $user_id ); - - if ( false === $updated ) { - exit( '' ); - } + apply_filters_deprecated( 'sensei_learners_learner_updated', array( $updated, $post_id, $progress->get_id() ), '$$next-version$$' ); exit( esc_html( $formatted_date ) ); } From c93921d96fba53e94920339ff86bdff7c0795c50 Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 10:17:14 -0400 Subject: [PATCH 06/15] Rename changelog: avoid outdated 'learner management' term Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog/add-hpps-learner-management-start-date | 4 ---- changelog/add-hpps-student-start-date | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 changelog/add-hpps-learner-management-start-date create mode 100644 changelog/add-hpps-student-start-date diff --git a/changelog/add-hpps-learner-management-start-date b/changelog/add-hpps-learner-management-start-date deleted file mode 100644 index 6feaf5268a..0000000000 --- a/changelog/add-hpps-learner-management-start-date +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: changed - -Migrate learner management start date editing to use progress repositories instead of direct comment meta access. diff --git a/changelog/add-hpps-student-start-date b/changelog/add-hpps-student-start-date new file mode 100644 index 0000000000..e132993eaf --- /dev/null +++ b/changelog/add-hpps-student-start-date @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Migrate student start date editing to use progress repositories instead of direct comment meta access. From 761bc7b2af51ef9f55b1549cfd081a3a01830f1e Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 10:58:03 -0400 Subject: [PATCH 07/15] Collapse duplicate progress repository get() call Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/admin/class-sensei-learners-main.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/includes/admin/class-sensei-learners-main.php b/includes/admin/class-sensei-learners-main.php index 84c9aed31c..5d7717f506 100644 --- a/includes/admin/class-sensei-learners-main.php +++ b/includes/admin/class-sensei-learners-main.php @@ -538,13 +538,10 @@ protected function get_row_data( $item ) { $learner_progress = null; if ( $post_id ) { - if ( 'course' === $post_type ) { - $progress_repository = Sensei()->course_progress_repository_factory->create(); - $learner_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); - } else { - $progress_repository = Sensei()->lesson_progress_repository_factory->create(); - $learner_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); - } + $progress_repository = 'course' === $post_type + ? Sensei()->course_progress_repository_factory->create() + : Sensei()->lesson_progress_repository_factory->create(); + $learner_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); } $learner_started_at = $learner_progress ? $learner_progress->get_started_at() : null; $date_started = $learner_started_at ? (string) wp_date( 'Y-m-d H:i:s', $learner_started_at->getTimestamp(), wp_timezone() ) : ''; From c1d0987ed0ca303c89c7d52894b83ffdbe32571f Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 11:18:19 -0400 Subject: [PATCH 08/15] Rename start date vars to student; drop redundant wp_timezone arg Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/admin/class-sensei-learners-main.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/admin/class-sensei-learners-main.php b/includes/admin/class-sensei-learners-main.php index 5d7717f506..b7561e5916 100644 --- a/includes/admin/class-sensei-learners-main.php +++ b/includes/admin/class-sensei-learners-main.php @@ -536,15 +536,15 @@ protected function get_row_data( $item ) { $actions[] = $edit_start_date_form; } - $learner_progress = null; + $student_progress = null; if ( $post_id ) { $progress_repository = 'course' === $post_type ? Sensei()->course_progress_repository_factory->create() : Sensei()->lesson_progress_repository_factory->create(); - $learner_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); + $student_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id ); } - $learner_started_at = $learner_progress ? $learner_progress->get_started_at() : null; - $date_started = $learner_started_at ? (string) wp_date( 'Y-m-d H:i:s', $learner_started_at->getTimestamp(), wp_timezone() ) : ''; + $student_started_at = $student_progress ? $student_progress->get_started_at() : null; + $date_started = $student_started_at ? (string) wp_date( 'Y-m-d H:i:s', $student_started_at->getTimestamp() ) : ''; $date_input = ''; /** From b467a7e46ad4735a4eacfbf39706def16446ef46 Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 11:36:05 -0400 Subject: [PATCH 09/15] Collapse duplicate progress repository save logic in edit_date_started Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/class-sensei-learner-management.php | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index ebfc3f0ac9..128767324c 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -523,26 +523,19 @@ public function edit_date_started() { $utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) ); - if ( 'course' === $post_type ) { - $repository = Sensei()->course_progress_repository_factory->create(); - $progress = $repository->get( $post_id, $user_id ); - if ( ! $progress ) { - exit( '' ); - } - $old_started_at = $progress->get_started_at(); - $progress->set_started_at( $utc_date ); - $repository->save( $progress ); - } else { - $repository = Sensei()->lesson_progress_repository_factory->create(); - $progress = $repository->get( $post_id, $user_id ); - if ( ! $progress ) { - exit( '' ); - } - $old_started_at = $progress->get_started_at(); - $progress->set_started_at( $utc_date ); - $repository->save( $progress ); + $repository = 'course' === $post_type + ? Sensei()->course_progress_repository_factory->create() + : Sensei()->lesson_progress_repository_factory->create(); + + $progress = $repository->get( $post_id, $user_id ); + if ( ! $progress ) { + exit( '' ); } + $old_started_at = $progress->get_started_at(); + $progress->set_started_at( $utc_date ); + $repository->save( $progress ); + $updated = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() ); $formatted_date = $date->format( 'Y-m-d H:i:s' ); From 7a71c5d4711eae0045970193ac69bf85da6e55ce Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 11:49:46 -0400 Subject: [PATCH 10/15] Restore no-op guard when start date is unchanged Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/admin/class-sensei-learner-management.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 128767324c..777b8a196c 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -551,6 +551,10 @@ public function edit_date_started() { */ apply_filters_deprecated( 'sensei_learners_learner_updated', array( $updated, $post_id, $progress->get_id() ), '$$next-version$$' ); + if ( ! $updated ) { + exit( '' ); + } + exit( esc_html( $formatted_date ) ); } From 9edf462aeacaf59db8dacc11d70860282051a911 Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 13:16:50 -0400 Subject: [PATCH 11/15] Remove useless test cases --- .../test-class-comments-based-course-progress.php | 15 --------------- .../test-class-tables-based-course-progress.php | 15 --------------- .../test-class-comments-based-lesson-progress.php | 15 --------------- .../test-class-tables-based-lesson-progress.php | 15 --------------- 4 files changed, 60 deletions(-) diff --git a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php index 655a7be6cb..326a2f8558 100644 --- a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php +++ b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-comments-based-course-progress.php @@ -50,21 +50,6 @@ public function testGetCompletedAt_WhenCompleteWithCompletedAtCalled_ReturnsSame self::assertSame( $completed_at, $progress->get_completed_at() ); } - /** - * Tests that set_started_at correctly updates the started_at value. - */ - public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { - /* Arrange. */ - $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); - $progress = $this->create_progress(); - - /* Act. */ - $progress->set_started_at( $started_at ); - - /* Assert. */ - self::assertSame( $started_at, $progress->get_started_at() ); - } - public function testGetStatus_WhenCompleteCalled_ReturnsMatchingStatus(): void { /* Arrange. */ $progress = $this->create_progress(); diff --git a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php index 71d8894ddf..4c490b8853 100644 --- a/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php +++ b/tests/unit-tests/internal/student-progress/course-progress/models/test-class-tables-based-course-progress.php @@ -50,21 +50,6 @@ public function testGetCompletedAt_WhenCompleteWithCompletedAtCalled_ReturnsSame self::assertSame( $completed_at, $progress->get_completed_at() ); } - /** - * Tests that set_started_at correctly updates the started_at value. - */ - public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { - /* Arrange. */ - $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); - $progress = $this->create_progress(); - - /* Act. */ - $progress->set_started_at( $started_at ); - - /* Assert. */ - self::assertSame( $started_at, $progress->get_started_at() ); - } - public function testGetStatus_WhenCompleteCalled_ReturnsMatchingStatus(): void { /* Arrange. */ $progress = $this->create_progress(); diff --git a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php index 626664ea7a..fa36b512a6 100644 --- a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php +++ b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-comments-based-lesson-progress.php @@ -210,21 +210,6 @@ public function testGetUpdatedAt_WhenUpdatedAtSet_ReturnsSameUpdatedAt(): void { self::assertSame( '2020-01-01 00:00:04', $actual ); } - /** - * Tests that set_started_at correctly updates the started_at value. - */ - public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { - /* Arrange. */ - $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); - $progress = $this->create_progress(); - - /* Act. */ - $progress->set_started_at( $started_at ); - - /* Assert. */ - self::assertSame( $started_at, $progress->get_started_at() ); - } - public function testGetStartedAt_WhenStartWithStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable(); diff --git a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php index 93471d4801..f1154a4505 100644 --- a/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php +++ b/tests/unit-tests/internal/student-progress/lesson-progress/models/test-class-tables-based-lesson-progress.php @@ -146,21 +146,6 @@ public function testGetUpdatedAt_WhenUpdatedAtSet_ReturnsSameUpdatedAt(): void { self::assertSame( '2020-01-01 00:00:04', $actual ); } - /** - * Tests that set_started_at correctly updates the started_at value. - */ - public function testGetStartedAt_WhenSetStartedAtCalled_ReturnsSameStartedAt(): void { - /* Arrange. */ - $started_at = new \DateTimeImmutable( '2023-06-15 10:30:00' ); - $progress = $this->create_progress(); - - /* Act. */ - $progress->set_started_at( $started_at ); - - /* Assert. */ - self::assertSame( $started_at, $progress->get_started_at() ); - } - public function testGetStartedAt_WhenStartWithStartedAtCalled_ReturnsSameStartedAt(): void { /* Arrange. */ $started_at = new \DateTimeImmutable(); From 471eddf8b6366f679c3e9c87968f39bfbb5f4889 Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 14:05:47 -0400 Subject: [PATCH 12/15] Add edit_date_started tests; skip redundant save on no-op Convert exit() to wp_die() in edit_date_started so the AJAX handler is catchable in tests, and move set_started_at()/save() after the no-op guard to avoid a redundant write when the date is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/class-sensei-learner-management.php | 29 ++++--- .../test-class-sensei-learner-management.php | 81 +++++++++++++++++++ 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 777b8a196c..7a7d7ebce1 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -470,25 +470,25 @@ public function edit_date_started() { if ( ! empty( $_POST['data']['post_id'] ) && is_numeric( $_POST['data']['post_id'] ) ) { $post_id = (int) sanitize_key( $_POST['data']['post_id'] ); } else { - exit( '' ); + wp_die(); } $post = get_post( $post_id ); if ( empty( $post ) || ! is_a( $post, 'WP_Post' ) ) { - exit( '' ); + wp_die(); } if ( ! empty( $_POST['data']['user_id'] ) && is_numeric( $_POST['data']['user_id'] ) ) { $user_id = absint( wp_unslash( $_POST['data']['user_id'] ) ); } else { - exit( '' ); + wp_die(); } $post_type = get_post_type( $post_id ); if ( ! in_array( $post_type, array( 'course', 'lesson' ), true ) ) { - exit( '' ); + wp_die(); } if ( 'lesson' === $post_type ) { @@ -498,17 +498,17 @@ public function edit_date_started() { } if ( ! $can_edit_date ) { - exit( '' ); + wp_die(); } if ( ! empty( $_POST['data']['new_dates']['start-date'] ) ) { $date_string = sanitize_text_field( wp_unslash( $_POST['data']['new_dates']['start-date'] ) ); } else { - exit( '' ); + wp_die(); } if ( empty( $date_string ) ) { - exit( '' ); + wp_die(); } $expected_date_format = 'Y-m-d H:i:s'; @@ -518,7 +518,7 @@ public function edit_date_started() { $date = DateTimeImmutable::createFromFormat( $expected_date_format, $date_string, wp_timezone() ); if ( false === $date ) { - exit( '' ); + wp_die(); } $utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) ); @@ -529,15 +529,11 @@ public function edit_date_started() { $progress = $repository->get( $post_id, $user_id ); if ( ! $progress ) { - exit( '' ); + wp_die(); } $old_started_at = $progress->get_started_at(); - $progress->set_started_at( $utc_date ); - $repository->save( $progress ); - $updated = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() ); - $formatted_date = $date->format( 'Y-m-d H:i:s' ); /** * Filter sensei_learners_learner_updated @@ -552,10 +548,13 @@ public function edit_date_started() { apply_filters_deprecated( 'sensei_learners_learner_updated', array( $updated, $post_id, $progress->get_id() ), '$$next-version$$' ); if ( ! $updated ) { - exit( '' ); + wp_die(); } - exit( esc_html( $formatted_date ) ); + $progress->set_started_at( $utc_date ); + $repository->save( $progress ); + + wp_die( esc_html( $date->format( 'Y-m-d H:i:s' ) ) ); } /** diff --git a/tests/unit-tests/admin/test-class-sensei-learner-management.php b/tests/unit-tests/admin/test-class-sensei-learner-management.php index 03897e0176..9678b1f432 100644 --- a/tests/unit-tests/admin/test-class-sensei-learner-management.php +++ b/tests/unit-tests/admin/test-class-sensei-learner-management.php @@ -97,4 +97,85 @@ public function testAddNewLearners_ToLessonWhenCurrentUserIsNotTeacher_ReturnsFa /* Assert. */ $this->assertFalse( $result ); } + + /** + * Tests that a valid start date edit is saved to the progress repository. + * + * @covers Sensei_Learner_Management::edit_date_started + */ + public function testEditDateStarted_ValidCourseDateGiven_SavesStartedAtToRepository() { + /* Arrange. */ + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + $student_id = $this->factory->user->create(); + $course_id = $this->factory->course->create(); + Sensei_Utils::user_start_course( $student_id, $course_id ); + + $start_date = '2024-01-15 10:30:00'; + + /* Act. */ + $response = $this->invoke_edit_date_started( + array( + 'post_id' => $course_id, + 'user_id' => $student_id, + 'new_dates' => array( 'start-date' => $start_date ), + ) + ); + + /* Assert. */ + $progress = Sensei()->course_progress_repository_factory->create()->get( $course_id, $student_id ); + $this->assertSame( $start_date, $response, 'Response should be the formatted start date.' ); + $this->assertSame( $start_date, $progress->get_started_at()->format( 'Y-m-d H:i:s' ), 'Start date should be saved to the repository.' ); + } + + /** + * Tests that submitting the same start date returns an empty response and does not report an update. + * + * @covers Sensei_Learner_Management::edit_date_started + */ + public function testEditDateStarted_UnchangedDateGiven_ReturnsEmptyResponse() { + /* Arrange. */ + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + $student_id = $this->factory->user->create(); + $course_id = $this->factory->course->create(); + Sensei_Utils::user_start_course( $student_id, $course_id ); + + $data = array( + 'post_id' => $course_id, + 'user_id' => $student_id, + 'new_dates' => array( 'start-date' => '2024-01-15 10:30:00' ), + ); + $this->invoke_edit_date_started( $data ); + + /* Act. */ + $response = $this->invoke_edit_date_started( $data ); + + /* Assert. */ + $this->assertSame( '', $response, 'Resubmitting the same date should return an empty response.' ); + } + + /** + * Invokes the edit_date_started AJAX handler and returns the response body. + * + * The handler always terminates via wp_die(); this captures the emitted body and + * fails the test if the handler returns without dying. Because of that, an empty + * return value always originates from the handler (wp_die( '' )), never from the helper. + * + * @param array $data The POST data payload. + * + * @return string The response body emitted by the handler. + */ + private function invoke_edit_date_started( array $data ): string { + $nonce = wp_create_nonce( 'edit_date_nonce' ); + $_REQUEST['security'] = $nonce; + $_POST['security'] = $nonce; + $_POST['data'] = $data; + + try { + $this->learner_management->edit_date_started(); + } catch ( \WPDieException $e ) { + return $e->getMessage(); + } + + $this->fail( 'Expected edit_date_started() to call wp_die().' ); + } } From 1938d21c2d7b0de9914b446609ac746abeaae9ef Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 14:17:47 -0400 Subject: [PATCH 13/15] Fix Psalm errors in edit_date_started Revert untested guard clauses to exit() so Psalm keeps type narrowing (wp_die() is not stubbed as noreturn), keeping wp_die() only on the tested no-op and success paths, and suppress the correlated-union warning on the repository save. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/class-sensei-learner-management.php | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 7a7d7ebce1..2af3653af0 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -470,25 +470,25 @@ public function edit_date_started() { if ( ! empty( $_POST['data']['post_id'] ) && is_numeric( $_POST['data']['post_id'] ) ) { $post_id = (int) sanitize_key( $_POST['data']['post_id'] ); } else { - wp_die(); + exit; } $post = get_post( $post_id ); if ( empty( $post ) || ! is_a( $post, 'WP_Post' ) ) { - wp_die(); + exit; } if ( ! empty( $_POST['data']['user_id'] ) && is_numeric( $_POST['data']['user_id'] ) ) { $user_id = absint( wp_unslash( $_POST['data']['user_id'] ) ); } else { - wp_die(); + exit; } $post_type = get_post_type( $post_id ); if ( ! in_array( $post_type, array( 'course', 'lesson' ), true ) ) { - wp_die(); + exit; } if ( 'lesson' === $post_type ) { @@ -498,17 +498,17 @@ public function edit_date_started() { } if ( ! $can_edit_date ) { - wp_die(); + exit; } if ( ! empty( $_POST['data']['new_dates']['start-date'] ) ) { $date_string = sanitize_text_field( wp_unslash( $_POST['data']['new_dates']['start-date'] ) ); } else { - wp_die(); + exit; } if ( empty( $date_string ) ) { - wp_die(); + exit; } $expected_date_format = 'Y-m-d H:i:s'; @@ -518,7 +518,7 @@ public function edit_date_started() { $date = DateTimeImmutable::createFromFormat( $expected_date_format, $date_string, wp_timezone() ); if ( false === $date ) { - wp_die(); + exit; } $utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) ); @@ -529,7 +529,7 @@ public function edit_date_started() { $progress = $repository->get( $post_id, $user_id ); if ( ! $progress ) { - wp_die(); + exit; } $old_started_at = $progress->get_started_at(); @@ -552,6 +552,13 @@ public function edit_date_started() { } $progress->set_started_at( $utc_date ); + + /** + * The repository and progress are created from the same factory, so their types + * always match, but Psalm cannot correlate the two union types. + * + * @psalm-suppress PossiblyInvalidArgument + */ $repository->save( $progress ); wp_die( esc_html( $date->format( 'Y-m-d H:i:s' ) ) ); From 55a937a7f2f8a69e98bb3811d5d071f0831746dd Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 15:13:24 -0400 Subject: [PATCH 14/15] Replace deprecated student-row filter with HPPS-safe sensei_students_row_updated Add sensei_students_row_updated, passing user_id and post_type so listeners work under both comments and tables storage, and honor its return value. Keep firing the deprecated sensei_learners_learner_updated for back compat, pointing it at the new hook. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/class-sensei-learner-management.php | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 2af3653af0..9628ed1243 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -532,35 +532,55 @@ public function edit_date_started() { exit; } - $old_started_at = $progress->get_started_at(); - $updated = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() ); + $old_started_at = $progress->get_started_at(); + $started_at_changed = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() ); + + if ( $started_at_changed ) { + $progress->set_started_at( $utc_date ); + + /** + * The repository and progress are created from the same factory, so their types + * always match, but Psalm cannot correlate the two union types. + * + * @psalm-suppress PossiblyInvalidArgument + */ + $repository->save( $progress ); + } + + /** + * Filters whether a student row was updated on the Students screen. + * + * Lets extensions persist additional date changes (e.g. course access dates) when a + * student's dates are edited and report whether a change occurred so the row refreshes. + * + * @since $$next-version$$ + * + * @hook sensei_students_row_updated + * + * @param {bool} $started_at_changed Whether the start date was changed. + * @param {int} $post_id Course or lesson ID. + * @param {int} $user_id Student user ID. + * @param {string} $post_type Post type ('course' or 'lesson'). + * @return {bool} Whether the student row was updated. + */ + $updated = (bool) apply_filters( 'sensei_students_row_updated', $started_at_changed, $post_id, $user_id, $post_type ); /** * Filter sensei_learners_learner_updated * - * @deprecated $$next-version$$ No replacement — this filter is no longer supported. + * @deprecated $$next-version$$ Use sensei_students_row_updated instead. * - * @param {bool} $updated A flag indicating if there was an update in the learner row. + * @param {bool} $updated A flag indicating if there was an update in the student row. * @param {int} $post_id Lesson or course id. - * @param {int} $comment_id The comment id which tracks the progress of the learner. + * @param {int} $comment_id The comment id which tracks the progress of the student. * @return {bool} False if there were no updates. */ - apply_filters_deprecated( 'sensei_learners_learner_updated', array( $updated, $post_id, $progress->get_id() ), '$$next-version$$' ); + $updated = (bool) apply_filters_deprecated( 'sensei_learners_learner_updated', array( $updated, $post_id, $progress->get_id() ), '$$next-version$$', 'sensei_students_row_updated' ); if ( ! $updated ) { wp_die(); } - $progress->set_started_at( $utc_date ); - - /** - * The repository and progress are created from the same factory, so their types - * always match, but Psalm cannot correlate the two union types. - * - * @psalm-suppress PossiblyInvalidArgument - */ - $repository->save( $progress ); - wp_die( esc_html( $date->format( 'Y-m-d H:i:s' ) ) ); } From ed41b7baf89e9bc6d11ed01a5de4d034822ac02a Mon Sep 17 00:00:00 2001 From: Donna Peplinskie Date: Fri, 19 Jun 2026 15:18:28 -0400 Subject: [PATCH 15/15] Address PR review: post_id sanitization, timezone-robust test, helper docs - Use absint( wp_unslash() ) for post_id instead of sanitize_key() - Assert saved start date via wp_date() in site timezone so the test is stable under HPPS/tables storage and non-UTC timezones - Correct the invoke helper docblock: only the tested paths wp_die(); validation paths use exit Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/admin/class-sensei-learner-management.php | 2 +- .../admin/test-class-sensei-learner-management.php | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/includes/admin/class-sensei-learner-management.php b/includes/admin/class-sensei-learner-management.php index 9628ed1243..b9470be55d 100644 --- a/includes/admin/class-sensei-learner-management.php +++ b/includes/admin/class-sensei-learner-management.php @@ -468,7 +468,7 @@ public function edit_date_started() { check_ajax_referer( 'edit_date_nonce', 'security' ); if ( ! empty( $_POST['data']['post_id'] ) && is_numeric( $_POST['data']['post_id'] ) ) { - $post_id = (int) sanitize_key( $_POST['data']['post_id'] ); + $post_id = absint( wp_unslash( $_POST['data']['post_id'] ) ); } else { exit; } diff --git a/tests/unit-tests/admin/test-class-sensei-learner-management.php b/tests/unit-tests/admin/test-class-sensei-learner-management.php index 9678b1f432..c74805ac5a 100644 --- a/tests/unit-tests/admin/test-class-sensei-learner-management.php +++ b/tests/unit-tests/admin/test-class-sensei-learner-management.php @@ -122,9 +122,10 @@ public function testEditDateStarted_ValidCourseDateGiven_SavesStartedAtToReposit ); /* Assert. */ - $progress = Sensei()->course_progress_repository_factory->create()->get( $course_id, $student_id ); + $progress = Sensei()->course_progress_repository_factory->create()->get( $course_id, $student_id ); + $saved_date = wp_date( 'Y-m-d H:i:s', $progress->get_started_at()->getTimestamp(), wp_timezone() ); $this->assertSame( $start_date, $response, 'Response should be the formatted start date.' ); - $this->assertSame( $start_date, $progress->get_started_at()->format( 'Y-m-d H:i:s' ), 'Start date should be saved to the repository.' ); + $this->assertSame( $start_date, $saved_date, 'Start date should be saved to the repository.' ); } /** @@ -156,9 +157,10 @@ public function testEditDateStarted_UnchangedDateGiven_ReturnsEmptyResponse() { /** * Invokes the edit_date_started AJAX handler and returns the response body. * - * The handler always terminates via wp_die(); this captures the emitted body and - * fails the test if the handler returns without dying. Because of that, an empty - * return value always originates from the handler (wp_die( '' )), never from the helper. + * The success and no-op paths exercised by these tests terminate via wp_die(), which the + * test framework throws as WPDieException; this captures the emitted body and fails the + * test if the handler returns without dying. (Validation-failure paths use exit and are + * not exercised here.) * * @param array $data The POST data payload. *