Moved birthdate into separate table.

This commit is contained in:
flash 2025-02-08 02:43:54 +00:00
parent 2c4d35e2dd
commit c28e0a90dd
12 changed files with 225 additions and 94 deletions

View file

@ -0,0 +1,40 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateUserBirthdatesTable_20250208_013647 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE msz_users_birthdates (
user_id INT(10) UNSIGNED NOT NULL,
birth_year SMALLINT(6) NULL DEFAULT NULL,
birth_month TINYINT(3) UNSIGNED NOT NULL,
birth_day TINYINT(3) UNSIGNED NOT NULL,
PRIMARY KEY (user_id),
INDEX users_birthdates_index (birth_month, birth_day),
CONSTRAINT users_birthdates_users_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT users_birthdates_ensure_month
CHECK (birth_month BETWEEN 1 AND 12),
CONSTRAINT users_birthdates_ensure_day
CHECK (birth_day BETWEEN 1 AND 31)
) ENGINE=InnoDB COLLATE='utf8mb4_bin';
SQL);
$conn->execute(<<<SQL
INSERT INTO msz_users_birthdates
SELECT user_id, IF(YEAR(user_birthdate) <= 1004, NULL, YEAR(user_birthdate)), MONTH(user_birthdate), DAY(user_birthdate)
FROM msz_users
WHERE user_birthdate IS NOT NULL
SQL);
$conn->execute(<<<SQL
ALTER TABLE msz_users
DROP COLUMN user_birthdate,
DROP INDEX users_birthdate_index;
SQL);
}
}

View file

@ -6,7 +6,7 @@ use InvalidArgumentException;
use RuntimeException;
use Index\ByteFormat;
use Misuzu\Parsers\Parser;
use Misuzu\Users\User;
use Misuzu\Users\{User,UsersContext};
use Misuzu\Users\Assets\UserAvatarAsset;
use Misuzu\Users\Assets\UserBackgroundAsset;
@ -93,6 +93,7 @@ if($isEditing) {
Template::set([
'perms' => $perms,
'background_attachments' => UserBackgroundAsset::getAttachmentStringOptions(),
'birthdate_info' => $msz->usersCtx->birthdates->getUserBirthdate($userInfo),
]);
if(!empty($_POST)) {
@ -172,11 +173,14 @@ if($isEditing) {
$birthYear = (int)($_POST['birthdate']['year'] ?? 0);
$birthMonth = (int)($_POST['birthdate']['month'] ?? 0);
$birthDay = (int)($_POST['birthdate']['day'] ?? 0);
$birthValid = $msz->usersCtx->users->validateBirthdate($birthYear, $birthMonth, $birthDay);
$birthValid = UsersContext::validateBirthdate($birthYear, $birthMonth, $birthDay);
if($birthValid === '')
$msz->usersCtx->users->updateUser($userInfo, birthYear: $birthYear, birthMonth: $birthMonth, birthDay: $birthDay);
else
if($birthValid === '') {
if($birthMonth === 0 && $birthDay === 0)
$msz->usersCtx->birthdates->deleteUserBirthdate($userInfo);
else
$msz->usersCtx->birthdates->updateUserBirthdate($userInfo, $birthYear === 0 ? null : $birthYear, $birthMonth, $birthDay);
} else
$notices[] = $msz->usersCtx->users->validateBirthdateText($birthValid);
}
}
@ -381,4 +385,5 @@ Template::render('profile.index', [
'profile_avatar_info' => $avatarInfo,
'profile_background_info' => $backgroundInfo,
'profile_can_send_messages' => $viewerPermsGlobal->check(Perm::G_MESSAGES_SEND),
'profile_age' => $msz->usersCtx->birthdates->getUserAge($userInfo),
]);

View file

@ -143,7 +143,7 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
$tmpFiles[] = db_to_zip($archive, $userInfo, 'perms_calculated', ['user_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_calculated:i']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'profile_fields_values', ['field_id:s', 'user_id:s', 'format_id:s', 'field_value:s']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'sessions', ['session_id:s', 'user_id:s', 'session_key:n', 'session_remote_addr_first:a', 'session_remote_addr_last:a:n', 'session_user_agent:s', 'session_country:s', 'session_expires:t', 'session_expires_bump:b', 'session_created:t', 'session_active:t:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users', ['user_id:s', 'user_name:s', 'user_password:n', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'user_display_role_id:s:n', 'user_about_content:s:n', 'user_about_parser:i', 'user_signature_content:s:n', 'user_signature_parser:i', 'user_birthdate:s:n', 'user_background_settings:i:n', 'user_title:s:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users', ['user_id:s', 'user_name:s', 'user_password:n', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'user_display_role_id:s:n', 'user_about_content:s:n', 'user_about_parser:i', 'user_signature_content:s:n', 'user_signature_parser:i', 'user_background_settings:i:n', 'user_title:s:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users_bans', ['ban_id:s', 'user_id:s', 'mod_id:n', 'ban_severity:i', 'ban_reason_public:s', 'ban_reason_private:s', 'ban_created:t', 'ban_expires:t:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users_password_resets', ['reset_id:s', 'user_id:s', 'reset_remote_addr:a', 'reset_requested:t', 'reset_code:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users_warnings', ['warn_id:s', 'user_id:s', 'mod_id:n', 'warn_body:s', 'warn_created:t']);

View file

@ -218,6 +218,7 @@ class HomeRoutes implements RouteHandler, UrlSource {
$birthdays[] = [
'info' => $birthdayInfo,
'colour' => $this->usersCtx->getUserColour($birthdayInfo),
'age' => $this->usersCtx->birthdates->getUserAge($birthdayInfo),
];
$newestMember = [];

View file

@ -0,0 +1,31 @@
<?php
namespace Misuzu\Users;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class UserBirthdateInfo {
public function __construct(
public private(set) string $userId,
public private(set) ?int $year,
public private(set) int $month,
public private(set) int $day
) {}
public static function fromResult(DbResult $result): UserBirthdateInfo {
return new UserBirthdateInfo(
userId: $result->getString(0),
year: $result->getIntegerOrNull(1),
month: $result->getInteger(2),
day: $result->getInteger(3),
);
}
public string $birthdate {
get => sprintf('%04d-%02d-%02d', $this->year ?? 0, $this->month, $this->day);
}
public CarbonImmutable $bornAt {
get => CarbonImmutable::createFromTimestampUTC('Y-m-d', $this->birthdate, 'UTC');
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace Misuzu\Users;
use DateTimeInterface;
use RuntimeException;
use Index\Db\{DbConnection,DbStatementCache};
class UserBirthdatesData {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function getUserBirthdate(UserInfo|string $userInfo): ?UserBirthdateInfo {
$stmt = $this->cache->get(<<<SQL
SELECT user_id, birth_year, birth_month, birth_day
FROM msz_users_birthdates
WHERE user_id = ?
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? UserBirthdateInfo::fromResult($result) : null;
}
public function deleteUserBirthdate(UserInfo|string $userInfo): void {
$stmt = $this->cache->get(<<<SQL
DELETE FROM msz_users_birthdates
WHERE user_id = ?
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->execute();
}
public function updateUserBirthdate(UserInfo|string $userInfo, ?int $year, int $month, int $day): UserBirthdateInfo {
$stmt = $this->cache->get(<<<SQL
REPLACE INTO msz_users_birthdates (
user_id, birth_year, birth_month, birth_day
) VALUES (?, ?, ?, ?)
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter($year);
$stmt->nextParameter($month);
$stmt->nextParameter($day);
$stmt->execute();
$birthdateInfo = $this->getUserBirthdate($userInfo);
if($birthdateInfo === null)
throw new RuntimeException('failed to set birthdate');
return $birthdateInfo;
}
public function getUserAge(UserInfo|string $userInfo): ?int {
$stmt = $this->cache->get(<<<SQL
SELECT TIMESTAMPDIFF(YEAR, CONCAT_WS('-', birth_year, birth_month, birth_day), CURDATE())
FROM msz_users_birthdates
WHERE user_id = ?
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? $result->getIntegerOrNull(0) : null;
}
}

View file

@ -25,7 +25,6 @@ class UserInfo {
public private(set) int $aboutBodyParser,
public private(set) ?string $signatureBody,
public private(set) int $signatureBodyParser,
public private(set) ?string $birthdateRaw,
public private(set) ?int $backgroundSettings,
public private(set) ?string $title,
) {}
@ -49,9 +48,8 @@ class UserInfo {
aboutBodyParser: $result->getInteger(14),
signatureBody: $result->getStringOrNull(15),
signatureBodyParser: $result->getInteger(16),
birthdateRaw: $result->getStringOrNull(17),
backgroundSettings: $result->getIntegerOrNull(18),
title: $result->getString(19),
backgroundSettings: $result->getIntegerOrNull(17),
title: $result->getString(18),
);
}
@ -114,21 +112,4 @@ class UserInfo {
public bool $isSignatureBodyMarkdown {
get => $this->signatureBodyParser === Parser::MARKDOWN;
}
public bool $hasBirthdate {
get => $this->birthdateRaw !== null;
}
public ?CarbonImmutable $birthdate {
get => $this->birthdateRaw === null ? null : CarbonImmutable::createFromFormat('Y-m-d', $this->birthdateRaw, 'UTC');
}
public int $age {
get {
$birthdate = $this->birthdate;
if($birthdate === null || (int)$birthdate->format('Y') < 1900)
return -1;
return (int)$birthdate->diff(CarbonImmutable::now())->format('%y');
}
}
}

View file

@ -3,6 +3,7 @@ namespace Misuzu\Users;
use Index\Colour\Colour;
use Index\Db\DbConnection;
use Misuzu\Tools;
class UsersContext {
public private(set) UsersData $users;
@ -11,6 +12,7 @@ class UsersContext {
public private(set) WarningsData $warnings;
public private(set) ModNotesData $modNotes;
public private(set) UserTotpsData $totps;
public private(set) UserBirthdatesData $birthdates;
/** @var array<string, UserInfo> */
private array $userInfos = [];
@ -31,6 +33,7 @@ class UsersContext {
$this->warnings = new WarningsData($dbConn);
$this->modNotes = new ModNotesData($dbConn);
$this->totps = new UserTotpsData($dbConn);
$this->birthdates = new UserBirthdatesData($dbConn);
}
public function getUserInfo(string $value, int|string|null $select = null): UserInfo {
@ -88,4 +91,19 @@ class UsersContext {
): bool {
return $this->tryGetActiveBan($userInfo, $minimumSeverity) !== null;
}
public static function validateBirthdate(?int $year, int $month, int $day, int $yearRange = 100): string {
$year ??= 0;
if($day !== 0 && $month !== 0) {
$currentYear = (int)date('Y');
if($year > 0 && ($year < $currentYear - $yearRange || $year > $currentYear))
return 'year';
if(!Tools::isValidDate($year, $month, $day))
return 'date';
}
return '';
}
}

View file

@ -57,19 +57,26 @@ class UsersData {
$hasDeleted = $deleted !== null;
$args = 0;
$query = 'SELECT COUNT(*) FROM msz_users';
$query = <<<SQL
SELECT COUNT(u.*) FROM msz_users
SQL;
if($hasRoleInfo)
$query .= <<<SQL
LEFT JOIN msz_users_roles AS ur ON u.user_id = ur.user_id
SQL;
if($hasRoleInfo) {
++$args;
$query .= ' WHERE user_id IN (SELECT user_id FROM msz_users_roles WHERE role_id = ?)';
$query .= ' WHERE ur.role_id = ?';
}
if($hasAfter)
$query .= sprintf(' %s user_id > ?', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_id > ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasLastActiveInMinutes)
$query .= sprintf(' %s user_active > NOW() - INTERVAL ? MINUTE', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_active > NOW() - INTERVAL ? MINUTE', ++$args > 1 ? 'AND' : 'WHERE');
if($hasNewerThanDays)
$query .= sprintf(' %s user_created > NOW() - INTERVAL ? DAY', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_created > NOW() - INTERVAL ? DAY', ++$args > 1 ? 'AND' : 'WHERE');
if($hasDeleted)
$query .= sprintf(' %s user_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
$query .= sprintf(' %s u.user_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
$stmt = $this->cache->get($query);
if($hasRoleInfo)
@ -163,34 +170,43 @@ class UsersData {
$args = 0;
$query = <<<SQL
SELECT user_id, user_name, user_password, user_email,
INET6_NTOA(user_remote_addr_first), INET6_NTOA(user_remote_addr_last),
user_super, user_country, user_colour,
UNIX_TIMESTAMP(user_created),
UNIX_TIMESTAMP(user_active),
UNIX_TIMESTAMP(user_deleted),
user_display_role_id, user_about_content,
user_about_parser, user_signature_content,
user_signature_parser, user_birthdate,
user_background_settings, user_title
FROM msz_users
SELECT u.user_id, u.user_name, u.user_password, u.user_email,
INET6_NTOA(u.user_remote_addr_first), INET6_NTOA(u.user_remote_addr_last),
u.user_super, u.user_country, u.user_colour,
UNIX_TIMESTAMP(u.user_created),
UNIX_TIMESTAMP(u.user_active),
UNIX_TIMESTAMP(u.user_deleted),
u.user_display_role_id,
u.user_about_content, u.user_about_parser,
u.user_signature_content, u.user_signature_parser,
u.user_background_settings, u.user_title
FROM msz_users AS u
SQL;
if($hasRoleInfo)
$query .= <<<SQL
LEFT JOIN msz_users_roles AS ur ON u.user_id = ur.user_id
SQL;
if($hasBirthdate)
$query .= <<<SQL
LEFT JOIN msz_users_birthdates AS ub ON u.user_id = ub.user_id
SQL;
if($hasRoleInfo) {
++$args;
$query .= ' WHERE user_id IN (SELECT user_id FROM msz_users_roles WHERE role_id = ?)';
$query .= ' WHERE ur.role_id = ?';
}
if($hasAfter)
$query .= sprintf(' %s user_id > ?', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_id > ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasLastActiveInMinutes)
$query .= sprintf(' %s user_active > NOW() - INTERVAL ? MINUTE', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_active > NOW() - INTERVAL ? MINUTE', ++$args > 1 ? 'AND' : 'WHERE');
if($hasNewerThanDays)
$query .= sprintf(' %s user_created > NOW() - INTERVAL ? DAY', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_created > NOW() - INTERVAL ? DAY', ++$args > 1 ? 'AND' : 'WHERE');
if($hasDeleted)
$query .= sprintf(' %s user_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
$query .= sprintf(' %s u.user_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
if($hasBirthdate)
$query .= sprintf(' %s user_birthdate LIKE ?', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s (ub.birth_month = ? AND ub.birth_day = ?)', ++$args > 1 ? 'AND' : 'WHERE');
if($hasSearchQuery)
$query .= sprintf(' %s user_name LIKE CONCAT("%%", ?, "%%")', ++$args > 1 ? 'AND' : 'WHERE');
$query .= sprintf(' %s u.user_name LIKE CONCAT("%%", ?, "%%")', ++$args > 1 ? 'AND' : 'WHERE');
if($hasOrderBy) {
$query .= sprintf(' ORDER BY %s', $orderBy[0]);
if($orderBy[1] !== null)
@ -210,8 +226,10 @@ class UsersData {
$stmt->nextParameter($lastActiveInMinutes);
if($hasNewerThanDays)
$stmt->nextParameter($newerThanDays);
if($hasBirthdate)
$stmt->nextParameter($birthdate->format('%-m-d'));
if($hasBirthdate) {
$stmt->nextParameter($birthdate->format('m'));
$stmt->nextParameter($birthdate->format('d'));
}
if($hasSearchQuery)
$stmt->nextParameter($searchQuery);
if($hasPagination)
@ -267,9 +285,9 @@ class UsersData {
UNIX_TIMESTAMP(user_created),
UNIX_TIMESTAMP(user_active),
UNIX_TIMESTAMP(user_deleted),
user_display_role_id, user_about_content,
user_about_parser, user_signature_content,
user_signature_parser, user_birthdate,
user_display_role_id,
user_about_content, user_about_parser,
user_signature_content, user_signature_parser,
user_background_settings, user_title
FROM msz_users
SQL;
@ -343,9 +361,6 @@ class UsersData {
?int $aboutBodyParser = null,
?string $signatureBody = null,
?int $signatureBodyParser = null,
?int $birthYear = null,
?int $birthMonth = null,
?int $birthDay = null,
?int $backgroundSettings = null,
?string $title = null
): void {
@ -413,18 +428,6 @@ class UsersData {
$values[] = $signatureBodyParser;
}
if($birthMonth !== null && $birthDay !== null) {
if(self::validateBirthdate($birthYear, $birthMonth, $birthDay) !== '')
throw new InvalidArgumentException('$birthYear, $birthMonth and $birthDay contain invalid data!');
// lowest leap year MariaDB accepts, used a 'no year' value
if($birthYear < 1004)
$birthYear = 1004;
$fields[] = 'user_birthdate = ?';
$values[] = $birthMonth < 1 || $birthDay < 1 ? null : sprintf('%04d-%02d-%02d', $birthYear, $birthMonth, $birthDay);
}
if($backgroundSettings !== null) {
$fields[] = 'user_background_settings = ?';
$values[] = $backgroundSettings;
@ -697,21 +700,6 @@ class UsersData {
};
}
public static function validateBirthdate(?int $year, int $month, int $day, int $yearRange = 100): string {
$year ??= 0;
if($day !== 0 && $month !== 0) {
$currentYear = (int)date('Y');
if($year > 0 && ($year < $currentYear - $yearRange || $year > $currentYear))
return 'year';
if(!Tools::isValidDate($year, $month, $day))
return 'date';
}
return '';
}
public static function validateBirthdateText(string $error): string {
return match($error) {
'year' => 'The year in your birthdate is too ridiculous.',

View file

@ -77,14 +77,14 @@
{{ container_title('<i class="fas fa-birthday-cake fa-fw"></i> Happy Birthday!') }}
{% for birthday in birthdays %}
{% set age = birthday.info.age %}
{% set age = birthday.age %}
<a class="landing__latest" style="--user-colour: {{ birthday.colour }}" href="{{ url('user-profile', {'user': birthday.info.id}) }}">
<div class="landing__latest__avatar">{{ avatar(birthday.info.id, 50, birthday.info.name) }}</div>
<div class="landing__latest__content">
<div class="landing__latest__username">
{{ birthday.info.name }}
</div>
{% if age > 0 %}
{% if age is not null %}
<div class="landing__latest__joined">
Turned {{ age }} today!
</div>

View file

@ -42,13 +42,12 @@
{% endif %}
{% set hasCountryCode = profile_user.countryCode != 'XX' %}
{% set age = profile_user.age %}
{% set hasAge = age > 0 %}
{% set hasAge = profile_age is not null %}
{% if hasCountryCode or hasAge %}
<div class="profile__header__country">
{% if hasCountryCode %}<div class="flag flag--{{ profile_user.countryCode|lower }}"></div>{% endif %}
<div class="profile__header__country__name">
{% if hasCountryCode %}{{ profile_user.countryCode|country_name }}{% endif %}{% if hasAge %}{% if hasCountryCode %}, {% endif %}{{ age }} year{{ age != 's' ? 's' : '' }} old{% endif %}
{% if hasCountryCode %}{{ profile_user.countryCode|country_name }}{% endif %}{% if hasAge %}{% if hasCountryCode %}, {% endif %}{{ profile_age }} year{{ profile_age != 1 ? 's' : '' }} old{% endif %}
</div>
</div>
{% endif %}

View file

@ -213,14 +213,14 @@
<div class="profile__birthdate__title">
Day
</div>
{{ input_select('birthdate[day]', ['-']|merge(range(1, 31)), profile_user.hasBirthdate ? profile_user.birthdate.day : 0, '', '', true, 'profile__birthdate__select profile__birthdate__select--day') }}
{{ input_select('birthdate[day]', ['-']|merge(range(1, 31)), birthdate_info.day|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--day') }}
</label>
<label class="profile__birthdate__label">
<div class="profile__birthdate__title">
Month
</div>
{{ input_select('birthdate[month]', ['-']|merge(range(1, 12)), profile_user.hasBirthdate ? profile_user.birthdate.month : 0, '', '', true, 'profile__birthdate__select profile__birthdate__select--month') }}
{{ input_select('birthdate[month]', ['-']|merge(range(1, 12)), birthdate_info.month|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--month') }}
</label>
</div>
@ -229,7 +229,7 @@
<div class="profile__birthdate__title">
Year (may be left empty)
</div>
{{ input_select('birthdate[year]', ['-']|merge(range(null|date('Y'), null|date('Y') - 100)), profile_user.birthdate.year|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--year') }}
{{ input_select('birthdate[year]', ['-']|merge(range(null|date('Y'), null|date('Y') - 100)), birthdate_info.year|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--year') }}
</label>
</div>
</div>