Collect a birth year instead of a full date of birth
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Signup and the profile page now ask for a four-digit year between 1900 and the current year. Anything else — a short year, a full date, a year in the future — is discarded rather than stored, so a typo cannot leave a nonsense age on the record. The year lives in a new us_birth_year user meta rather than reusing us_date_of_birth, which would have left one key holding two formats. The old key is not migrated in bulk. Instead GuardianService handles it in two halves: birthYear() falls back to the year of the old date when the new key is absent, so a student added before this change still shows one, and setBirthYear() deletes the old date on every save. That deletion is what makes the fallback safe rather than merely tidy. Without it, clearing the birth year on a student who predates the change would leave the old date behind for the fallback to read straight back, and the year could never be cleared at all. Stored in user meta, so no Schema.php change and no USC_VERSION bump. Closes #147 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -477,11 +477,11 @@ class RegistrationPage {
|
||||
|
||||
/**
|
||||
* The child blocks submitted with a guardian signup, as
|
||||
* `children[<n>][name|dob|answers]`. Blocks with no name are dropped rather
|
||||
* `children[<n>][name|birth_year|answers]`. Blocks with no name are dropped rather
|
||||
* than rejected — the form always renders one spare block for "add another",
|
||||
* and an untouched spare is not a mistake the guardian needs telling about.
|
||||
*
|
||||
* @return list<array{name: string, dob: string, answers: array<int, string>}>
|
||||
* @return list<array{name: string, birth_year: string, answers: array<int, string>}>
|
||||
*/
|
||||
private function submittedChildren(): array {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
@@ -508,9 +508,9 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ),
|
||||
'answers' => $answers,
|
||||
'name' => $name,
|
||||
'birth_year' => sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ),
|
||||
'answers' => $answers,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -528,7 +528,7 @@ class RegistrationPage {
|
||||
* re-register and children they never confirmed, so it is undone entirely and
|
||||
* they simply try again.
|
||||
*
|
||||
* @param list<array{name: string, dob: string, answers: array<int, string>}> $children
|
||||
* @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
|
||||
* @param list<Question> $questions
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
@@ -536,7 +536,7 @@ class RegistrationPage {
|
||||
$created = [];
|
||||
|
||||
foreach ( $children as $child ) {
|
||||
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] );
|
||||
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
|
||||
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
foreach ( $created as $id ) {
|
||||
|
||||
@@ -187,11 +187,11 @@ class BlockPreview {
|
||||
|
||||
$add = sprintf(
|
||||
'<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>'
|
||||
. '<p><label for="us-child-dob">%s</label><input type="date" id="us-child-dob"></p>'
|
||||
. '<p><label for="us-child-birth-year">%s</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
|
||||
. '<p><button type="button" disabled>%s</button></p>',
|
||||
esc_html__( 'Add a student', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Name', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Date of birth', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Birth year', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Add student', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class FamilyPage {
|
||||
*/
|
||||
private function handleAdd( int $guardianId ): string|\WP_Error {
|
||||
$name = $this->postString( 'child_name' );
|
||||
$dateOfBirth = $this->postString( 'child_dob' );
|
||||
$birthYear = $this->postString( 'child_birth_year' );
|
||||
$relationship = $this->postString( 'child_relationship' );
|
||||
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
@@ -130,7 +130,7 @@ class FamilyPage {
|
||||
return $missing;
|
||||
}
|
||||
|
||||
$childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship );
|
||||
$childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
return $childId;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ class FamilyPage {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||
|
||||
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
|
||||
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
|
||||
|
||||
return $error ?? self::RESULT_UPDATED;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,24 @@ class GuardianService {
|
||||
*/
|
||||
public const META_CHILD = 'us_child';
|
||||
|
||||
/** A child's date of birth (`Y-m-d`), collected at signup and editable after. */
|
||||
/** A child's birth year (`YYYY`), collected at signup and editable after. */
|
||||
public const META_BIRTH_YEAR = 'us_birth_year';
|
||||
|
||||
/**
|
||||
* The full date of birth this feature used to collect. Nothing writes it any
|
||||
* more: it is read once, to derive a birth year for a child who predates the
|
||||
* change, and cleared the moment that child's record is next saved. Kept
|
||||
* public so a site that wants to purge the old dates outright can find them.
|
||||
*/
|
||||
public const META_DOB = 'us_date_of_birth';
|
||||
|
||||
/**
|
||||
* The earliest birth year the form will accept. Old enough for any student a
|
||||
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
|
||||
* that would otherwise be stored as a plausible-looking year.
|
||||
*/
|
||||
private const MIN_BIRTH_YEAR = 1900;
|
||||
|
||||
/**
|
||||
* Domain used for a child's placeholder login address. `.invalid` is reserved
|
||||
* by RFC 2606 and can never resolve, so a child's address is guaranteed
|
||||
@@ -48,7 +63,7 @@ class GuardianService {
|
||||
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress
|
||||
* refuses the insert.
|
||||
*/
|
||||
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error {
|
||||
public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
|
||||
@@ -73,7 +88,7 @@ class GuardianService {
|
||||
$userId = (int) $userId;
|
||||
|
||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
||||
$this->setDateOfBirth( $userId, $dateOfBirth );
|
||||
$this->setBirthYear( $userId, $birthYear );
|
||||
|
||||
$linkId = $this->guardians->insert(
|
||||
new GuardianLink(
|
||||
@@ -96,13 +111,13 @@ class GuardianService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a child and update their date of birth. Refuses a student the caller
|
||||
* Rename a child and update their birth year. Refuses a student the caller
|
||||
* is not the guardian of, so the family screen cannot be turned into an
|
||||
* arbitrary user editor by posting someone else's id.
|
||||
*
|
||||
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
|
||||
*/
|
||||
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error {
|
||||
public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
@@ -124,7 +139,7 @@ class GuardianService {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->setDateOfBirth( $studentId, $dateOfBirth );
|
||||
$this->setBirthYear( $studentId, $birthYear );
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -235,7 +250,7 @@ class GuardianService {
|
||||
* A guardian's children, in link order, with the details the family and admin
|
||||
* screens display.
|
||||
*
|
||||
* @return list<array{id: int, name: string, date_of_birth: string, relationship: string}>
|
||||
* @return list<array{id: int, name: string, birth_year: string, relationship: string}>
|
||||
*/
|
||||
public function children( int $guardianId ): array {
|
||||
$out = [];
|
||||
@@ -244,10 +259,10 @@ class GuardianService {
|
||||
$user = get_userdata( $link->studentId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $link->studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
||||
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ),
|
||||
'relationship' => $link->relationship,
|
||||
'id' => $link->studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
||||
'birth_year' => $this->birthYear( $link->studentId ),
|
||||
'relationship' => $link->relationship,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -327,24 +342,64 @@ class GuardianService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a child's date of birth, or clear it when blank or unparseable. Kept
|
||||
* as `Y-m-d` so it sorts and displays consistently wherever it is read.
|
||||
* Store a child's birth year, or clear it when blank or out of range.
|
||||
*
|
||||
* Either way the legacy full date of birth goes with it. That is what makes
|
||||
* the read fallback in {@see birthYear()} safe: without it, clearing the year
|
||||
* on a child who predates this change would leave the old date behind for the
|
||||
* fallback to resurrect on the very next read.
|
||||
*/
|
||||
private function setDateOfBirth( int $userId, string $dateOfBirth ): void {
|
||||
$dateOfBirth = trim( $dateOfBirth );
|
||||
private function setBirthYear( int $userId, string $birthYear ): void {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
|
||||
if ( '' === $dateOfBirth ) {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
$year = $this->normaliseBirthYear( $birthYear );
|
||||
|
||||
if ( 0 === $year ) {
|
||||
delete_user_meta( $userId, self::META_BIRTH_YEAR );
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth );
|
||||
if ( false === $parsed ) {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
return;
|
||||
update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
|
||||
}
|
||||
|
||||
/**
|
||||
* A submitted birth year as an integer, or 0 when it is blank, not a number,
|
||||
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
|
||||
* every time, so it is refused rather than stored.
|
||||
*/
|
||||
private function normaliseBirthYear( string $birthYear ): int {
|
||||
$birthYear = trim( $birthYear );
|
||||
|
||||
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) );
|
||||
$year = (int) $birthYear;
|
||||
|
||||
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
/**
|
||||
* A child's birth year, or an empty string when none is recorded.
|
||||
*
|
||||
* Falls back to the year of the full date of birth this feature used to
|
||||
* collect, so a child added before the change still shows one. The fallback
|
||||
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
|
||||
* as the record is saved again.
|
||||
*/
|
||||
private function birthYear( int $userId ): string {
|
||||
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
|
||||
if ( '' !== $year ) {
|
||||
return $year;
|
||||
}
|
||||
|
||||
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
|
||||
|
||||
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user