OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
619
app/Domain/Users/Repositories/Users.php
Normal file
619
app/Domain/Users/Repositories/Users.php
Normal file
@@ -0,0 +1,619 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Users\Repositories;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Db\DatabaseHelper;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Domain\Files\Repositories\Files;
|
||||
use Leantime\Domain\Users\Enums\EmploymentType;
|
||||
|
||||
class Users
|
||||
{
|
||||
private ConnectionInterface $connection;
|
||||
|
||||
public string $user;
|
||||
|
||||
public string $lastname;
|
||||
|
||||
public string $firstname;
|
||||
|
||||
public int $role;
|
||||
|
||||
public string $jobTitle;
|
||||
|
||||
public string $jobLevel;
|
||||
|
||||
public string $department;
|
||||
|
||||
public int $id;
|
||||
|
||||
public array $adminRoles = [40, 50];
|
||||
|
||||
public array $status = ['active' => 'label.active', 'inactive' => 'label.inactive', 'invited' => 'label.invited'];
|
||||
|
||||
/**
|
||||
* Request-scoped memo for getUser(), keyed by user id. Cleared on writes.
|
||||
*
|
||||
* @var array<int|string, array|bool>
|
||||
*/
|
||||
private array $userMemo = [];
|
||||
|
||||
/**
|
||||
* __construct - neu db connection
|
||||
*/
|
||||
public function __construct(
|
||||
protected Environment $config,
|
||||
protected DbCore $db,
|
||||
protected DatabaseHelper $dbHelper,
|
||||
protected Files $files
|
||||
) {
|
||||
$this->connection = $db->getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* getUser - get on user from db
|
||||
*/
|
||||
public function getUser($id): array|bool
|
||||
{
|
||||
// Request-scoped memo: getUser is hit repeatedly per request for
|
||||
// author/role/avatar lookups. Cleared by editUser/patchUser/deleteUser.
|
||||
if (array_key_exists($id, $this->userMemo)) {
|
||||
return $this->userMemo[$id];
|
||||
}
|
||||
|
||||
$result = $this->connection->table('zp_user')
|
||||
->where('id', $id)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $this->userMemo[$id] = $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* getUser - get on user from db
|
||||
*/
|
||||
public function getUserBySha($hash): array|false
|
||||
{
|
||||
$result = $this->connection->table('zp_user')
|
||||
->whereRaw('SHA1(CONCAT(id, ?)) = ?', [$this->config->sessionPassword, $hash])
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* getLastLogin - get the date of the last login of any user
|
||||
*
|
||||
* @return string|null returns datetime string with last login or null if nothing could be found
|
||||
*/
|
||||
public function getLastLogin(): ?string
|
||||
{
|
||||
$result = $this->connection->table('zp_user')
|
||||
->select('lastlogin')
|
||||
->orderByDesc('lastlogin')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result->lastlogin ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* getUserByEmail - get on user from db
|
||||
*/
|
||||
public function getUserByEmail(string $email, string $status = 'a'): array|false
|
||||
{
|
||||
$query = $this->connection->table('zp_user')
|
||||
->where('username', $email);
|
||||
|
||||
if ($status === 'a') {
|
||||
$query->whereRaw('LOWER(status) = ?', ['a']);
|
||||
}
|
||||
|
||||
if ($status === 'i') {
|
||||
$query->whereRaw('LOWER(status) = ?', ['i']);
|
||||
}
|
||||
|
||||
$result = $query->limit(1)->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
public function getNumberOfUsers($activeOnly = false, $includeApi = true): int
|
||||
{
|
||||
$query = $this->connection->table('zp_user')
|
||||
->selectRaw('COUNT(id) AS '.$this->dbHelper->wrapColumn('userCount'));
|
||||
|
||||
if ($activeOnly) {
|
||||
$query->where('status', 'a');
|
||||
}
|
||||
|
||||
if ($includeApi) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('source', '!=', 'api')
|
||||
->orWhereNull('source');
|
||||
});
|
||||
}
|
||||
|
||||
$result = $query->first();
|
||||
|
||||
return $result->userCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* getEmployees - get all employees
|
||||
*/
|
||||
public function getEmployees(): array
|
||||
{
|
||||
$results = $this->connection->table('zp_user')
|
||||
->select([
|
||||
'zp_user.id',
|
||||
'zp_user.lastname',
|
||||
'zp_user.jobTitle',
|
||||
'zp_user.jobLevel',
|
||||
'zp_user.department',
|
||||
'zp_user.modified',
|
||||
])
|
||||
->selectRaw('COALESCE(zp_user.firstname, zp_user.username) AS firstname')
|
||||
->orderBy('lastname')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* getAll - get all user
|
||||
*
|
||||
* @param bool $activeOnly
|
||||
*/
|
||||
public function getAll($activeOnly = false): array
|
||||
{
|
||||
$query = $this->connection->table('zp_user')
|
||||
->select([
|
||||
'zp_user.id',
|
||||
'lastname',
|
||||
'role',
|
||||
'profileId',
|
||||
'status',
|
||||
'username',
|
||||
'twoFAEnabled',
|
||||
'clientId',
|
||||
'zp_clients.name as clientName',
|
||||
'jobTitle',
|
||||
'jobLevel',
|
||||
'department',
|
||||
'zp_user.modified',
|
||||
])
|
||||
->selectRaw("CASE WHEN firstname <> '' THEN firstname ELSE username END AS firstname")
|
||||
->leftJoin('zp_clients', 'zp_clients.id', '=', 'zp_user.clientId')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('source')
|
||||
->orWhere('source', '!=', 'api');
|
||||
});
|
||||
|
||||
if ($activeOnly) {
|
||||
$query->where('status', 'like', 'a');
|
||||
}
|
||||
|
||||
$results = $query->orderBy('lastname')->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
public function getAllBySource($source): false|array
|
||||
{
|
||||
$query = $this->connection->table('zp_user')
|
||||
->select([
|
||||
'zp_user.id',
|
||||
'lastname',
|
||||
'firstname',
|
||||
'role',
|
||||
'profileId',
|
||||
'status',
|
||||
'username',
|
||||
'lastlogin',
|
||||
'createdOn',
|
||||
'jobTitle',
|
||||
'jobLevel',
|
||||
'department',
|
||||
'modified',
|
||||
]);
|
||||
|
||||
if ($source === null) {
|
||||
$query->whereNull('source');
|
||||
} else {
|
||||
$query->where('source', $source);
|
||||
}
|
||||
|
||||
$results = $query->orderBy('lastname')->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* getAll - get all user
|
||||
*/
|
||||
public function getAllClientUsers($clientId): array
|
||||
{
|
||||
$results = $this->connection->table('zp_user')
|
||||
->select([
|
||||
'zp_user.id',
|
||||
'lastname',
|
||||
'firstname',
|
||||
'role',
|
||||
'profileId',
|
||||
'status',
|
||||
'username',
|
||||
'twoFAEnabled',
|
||||
'zp_clients.name as clientName',
|
||||
'jobTitle',
|
||||
'jobLevel',
|
||||
'department',
|
||||
'modified',
|
||||
])
|
||||
->leftJoin('zp_clients', 'zp_clients.id', '=', 'zp_user.clientId')
|
||||
->where('clientId', $clientId)
|
||||
->orderBy('lastname')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
public function isAdmin($userId): bool
|
||||
{
|
||||
$result = $this->connection->table('zp_user')
|
||||
->select('role')
|
||||
->where('id', $userId)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
if ($result && in_array($result->role, $this->adminRoles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* editUSer - edit user
|
||||
*/
|
||||
public function editUser(array $values, $id): bool
|
||||
{
|
||||
unset($this->userMemo[$id]);
|
||||
|
||||
$updateData = [
|
||||
'firstname' => $values['firstname'],
|
||||
'lastname' => $values['lastname'],
|
||||
'username' => $values['user'],
|
||||
'phone' => $values['phone'] ?? '',
|
||||
'status' => $values['status'],
|
||||
'role' => $values['role'],
|
||||
'hours' => $values['hours'] ?? 0,
|
||||
'wage' => $values['wage'] ?? 0,
|
||||
'clientId' => $values['clientId'],
|
||||
'jobTitle' => $values['jobTitle'] ?? '',
|
||||
'jobLevel' => $values['jobLevel'] ?? '',
|
||||
'department' => $values['department'] ?? '',
|
||||
'modified' => now(),
|
||||
];
|
||||
|
||||
// Capacity attributes (v3.5.23) — only overwrite when explicitly
|
||||
// present so an admin form that omits the fields (or a legacy
|
||||
// caller) doesn't null out a value already set. Validation runs
|
||||
// at this boundary so every caller (form controller, service,
|
||||
// JSON-RPC) gets the same guarantees: unrecognised employment
|
||||
// types normalise to NULL; weekly_hours outside a sane range
|
||||
// normalises to NULL.
|
||||
if (array_key_exists('weekly_hours', $values)) {
|
||||
$updateData['weekly_hours'] = $this->normalizeWeeklyHours($values['weekly_hours']);
|
||||
}
|
||||
if (array_key_exists('employment_type', $values)) {
|
||||
$updateData['employment_type'] = $this->normalizeEmploymentType($values['employment_type']);
|
||||
}
|
||||
|
||||
if (isset($values['password']) && $values['password'] != '' && ! $this->isHashedPassword($values['password'])) {
|
||||
$updateData['password'] = password_hash($values['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
return $this->connection->table('zp_user')
|
||||
->where('id', $id)
|
||||
->update($updateData) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* usernameExist - Check if a username is already in db
|
||||
*/
|
||||
public function usernameExist($username, string $userId = ''): bool
|
||||
{
|
||||
$query = $this->connection->table('zp_user')
|
||||
->selectRaw('COUNT(username) AS '.$this->dbHelper->wrapColumn('numUser'))
|
||||
->where('username', $username);
|
||||
|
||||
if ($userId != '') {
|
||||
$query->where('id', '!=', $userId);
|
||||
}
|
||||
|
||||
$result = $query->limit(1)->first();
|
||||
|
||||
return (int) $result->numUser === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeFromClient - Remove user from client by setting clientId to null
|
||||
*
|
||||
* @param int $userId User ID to remove from client
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeFromClient(int $userId): bool
|
||||
{
|
||||
return $this->connection->table('zp_user')
|
||||
->where('id', $userId)
|
||||
->update([
|
||||
'clientId' => null,
|
||||
'modified' => now(),
|
||||
]) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* editOwn - Edit own Userdates
|
||||
*/
|
||||
public function editOwn($values, $id): void
|
||||
{
|
||||
$updateData = [
|
||||
'lastname' => $values['lastname'],
|
||||
'firstname' => $values['firstname'],
|
||||
'username' => $values['user'],
|
||||
'phone' => $values['phone'],
|
||||
'notifications' => $values['notifications'],
|
||||
'modified' => now(),
|
||||
];
|
||||
|
||||
if (isset($values['password']) && $values['password'] != '' && ! $this->isHashedPassword($values['password'])) {
|
||||
$updateData['password'] = password_hash($values['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$this->connection->table('zp_user')
|
||||
->where('id', $id)
|
||||
->update($updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* isHashedPassword - Detect a value that is already a password hash so callers that pass a
|
||||
* full user row (e.g. the OIDC login sync) don't re-hash the stored hash and lock the user out.
|
||||
*/
|
||||
private function isHashedPassword(string $password): bool
|
||||
{
|
||||
return ! empty(password_get_info($password)['algo']);
|
||||
}
|
||||
|
||||
/**
|
||||
* addUser - add User to db
|
||||
*/
|
||||
public function addUser(array $values): false|string
|
||||
{
|
||||
$userId = $this->connection->table('zp_user')->insertGetId([
|
||||
'firstname' => $values['firstname'] ?? '',
|
||||
'lastname' => $values['lastname'] ?? '',
|
||||
'phone' => $values['phone'] ?? '',
|
||||
'username' => $values['user'],
|
||||
'role' => $values['role'],
|
||||
'notifications' => 1,
|
||||
'clientId' => $values['clientId'] ?? '',
|
||||
'password' => password_hash($values['password'], PASSWORD_DEFAULT),
|
||||
'source' => $values['source'] ?? '',
|
||||
'pwReset' => $values['pwReset'] ?? '',
|
||||
'status' => $values['status'] ?? '',
|
||||
'createdOn' => now(),
|
||||
'jobTitle' => $values['jobTitle'] ?? '',
|
||||
'jobLevel' => $values['jobLevel'] ?? '',
|
||||
'department' => $values['department'] ?? '',
|
||||
'weekly_hours' => $this->normalizeWeeklyHours($values['weekly_hours'] ?? null),
|
||||
'employment_type' => $this->normalizeEmploymentType($values['employment_type'] ?? null),
|
||||
'modified' => now(),
|
||||
]);
|
||||
|
||||
return (string) $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteUser - delete user from db
|
||||
*/
|
||||
public function deleteUser($id): void
|
||||
{
|
||||
unset($this->userMemo[$id]);
|
||||
|
||||
$this->connection->table('zp_user')
|
||||
->where('zp_user.id', $id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* setPicture - set the profile picture for an individual
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function setPicture($fileId, $id): bool
|
||||
{
|
||||
return $this->connection->table('zp_user')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'profileId' => $fileId,
|
||||
'modified' => dtHelper()->dbNow()->formatDateTimeForDb(),
|
||||
]) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getProfilePicture($id): array|false
|
||||
{
|
||||
if ($id === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->connection->table('zp_user')
|
||||
->select(['profileId', 'firstname', 'lastname'])
|
||||
->where('id', $id)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
public function patchUser($id, $params): bool
|
||||
{
|
||||
unset($this->userMemo[$id]);
|
||||
|
||||
$updates = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$cleanKey = DbCore::sanitizeToColumnString($key);
|
||||
if ($cleanKey === 'password') {
|
||||
$updates[$cleanKey] = password_hash($value, PASSWORD_DEFAULT);
|
||||
} else {
|
||||
$updates[$cleanKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$updates['modified'] = now();
|
||||
|
||||
return $this->connection->table('zp_user')
|
||||
->where('id', $id)
|
||||
->update($updates) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* getUserIdByName - Get Author/User Id by first- and lastname
|
||||
*
|
||||
* @param string $lastname Lastname
|
||||
* @return int|bool Identifier of user or false, if not found
|
||||
*/
|
||||
public function getUserIdByName(string $firstname, string $lastname): int|bool
|
||||
{
|
||||
$result = $this->connection->table('zp_user')
|
||||
->select('profileId')
|
||||
->where('firstname', $firstname)
|
||||
->where('lastname', $lastname)
|
||||
->first();
|
||||
|
||||
return $result->profileId ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user settings - retrieves and deserializes user settings
|
||||
*
|
||||
* @param int $userId The user ID to get settings for
|
||||
* @param string|null $settingPath Optional dot notation path to retrieve specific setting (e.g. 'onboarding.firstLoginCompleted')
|
||||
* @return mixed The requested settings or specific setting value, empty array if no settings exist
|
||||
*/
|
||||
public function getUserSettings(int $userId, ?string $settingPath = null): mixed
|
||||
{
|
||||
$result = $this->connection->table('zp_user')
|
||||
->select('settings')
|
||||
->where('id', $userId)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
// If no settings exist yet, return empty array
|
||||
if (! $result || empty($result->settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try to unserialize the settings
|
||||
try {
|
||||
$settings = safe_unserialize($result->settings, []);
|
||||
|
||||
// Ensure settings is an array (unserialize may return stdClass)
|
||||
if (is_object($settings)) {
|
||||
$settings = json_decode(json_encode($settings), true);
|
||||
}
|
||||
|
||||
if (! is_array($settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If we have a specific path to retrieve
|
||||
if ($settingPath !== null) {
|
||||
return $this->getNestedSetting($settings, $settingPath);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
} catch (\Exception $e) {
|
||||
// If there's an error unserializing, return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get a nested setting using dot notation
|
||||
*
|
||||
* @param array $settings The settings array
|
||||
* @param string $path Dot notation path (e.g. 'onboarding.firstLoginCompleted')
|
||||
* @return mixed The setting value or null if not found
|
||||
*/
|
||||
private function getNestedSetting(array $settings, string $path)
|
||||
{
|
||||
$keys = explode('.', $path);
|
||||
$current = $settings;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (! is_array($current) || ! isset($current[$key])) {
|
||||
return null;
|
||||
}
|
||||
$current = $current[$key];
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a POSTed weekly_hours value into the persisted shape.
|
||||
* Accepts numeric input; rejects anything outside 0..168 (168 =
|
||||
* hours in a week, the largest value that has physical meaning).
|
||||
* Anything that fails validation becomes NULL — treated by the
|
||||
* capacity math as "not configured" rather than saving garbage.
|
||||
*
|
||||
* Fractional input is ROUNDED, not truncated. The column is an int
|
||||
* and the edit-user field is a step-1 number input, so fractions
|
||||
* only reach here from the API — where a plain (int) cast silently
|
||||
* turned 37.5 into 37. Rounding keeps the value closest to intent;
|
||||
* rejecting it outright would be worse, since NULL removes the
|
||||
* person from capacity math entirely.
|
||||
*
|
||||
* NOTE: the int column cannot represent genuinely fractional
|
||||
* contracts (37.5 h/wk is common part-time). Storing minutes, or
|
||||
* widening the column, is the real fix if that case matters.
|
||||
*/
|
||||
private function normalizeWeeklyHours(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
$hours = (int) round((float) $value);
|
||||
if ($hours < 0 || $hours > 168) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a POSTed employment_type into one of the four enum
|
||||
* cases or NULL. Any string that isn't a recognised case is
|
||||
* silently normalised to NULL so a crafted POST can't persist
|
||||
* garbage that later EmploymentType::from() would throw on.
|
||||
*/
|
||||
private function normalizeEmploymentType(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$case = EmploymentType::tryFrom((string) $value);
|
||||
|
||||
return $case?->value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user