407 lines
15 KiB
PHP
407 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Leantime\Domain\Notifications\Services;
|
|
|
|
use DOMDocument;
|
|
use Illuminate\Contracts\Container\BindingResolutionException;
|
|
use Leantime\Core\Language as LanguageCore;
|
|
use Leantime\Core\Mailer as MailerCore;
|
|
use Leantime\Core\Support\NameSanitizer;
|
|
use Leantime\Domain\Notifications\Repositories\Notifications as NotificationRepository;
|
|
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
|
|
|
/**
|
|
* @api
|
|
*/
|
|
class Notifications
|
|
{
|
|
private NotificationRepository $notificationsRepo;
|
|
|
|
private UserRepository $userRepository;
|
|
|
|
private LanguageCore $language;
|
|
|
|
/**
|
|
* __construct - get database connection
|
|
*
|
|
*
|
|
* @api
|
|
*/
|
|
public function __construct(
|
|
NotificationRepository $notificationsRepo,
|
|
UserRepository $userRepository,
|
|
LanguageCore $language
|
|
) {
|
|
$this->notificationsRepo = $notificationsRepo;
|
|
$this->userRepository = $userRepository;
|
|
$this->language = $language;
|
|
}
|
|
|
|
/**
|
|
* Not exposed via JSON-RPC: it accepts an arbitrary $userId.
|
|
*/
|
|
public function getAllNotifications($userId, bool $showNewOnly = false, int $limitStart = 0, int $limitEnd = 100, array $filterOptions = []): false|array
|
|
{
|
|
|
|
return $this->notificationsRepo->getAllNotifications($userId, $showNewOnly, $limitStart, $limitEnd, $filterOptions);
|
|
}
|
|
|
|
/**
|
|
* @api
|
|
*/
|
|
public function addNotifications(array $notifications): ?bool
|
|
{
|
|
|
|
return $this->notificationsRepo->addNotifications($notifications);
|
|
}
|
|
|
|
/**
|
|
* consumeFlashNotification - reads the pending growl/flash notification from the
|
|
* session, clears the relevant session keys (read-once semantics) and returns the
|
|
* assembled payload.
|
|
*
|
|
* Returns null when there is no pending notification.
|
|
*
|
|
* @return array{notification: string, type: string, eventId: string}|null
|
|
*
|
|
* @api
|
|
*/
|
|
public function consumeFlashNotification(): ?array
|
|
{
|
|
if (session('notification') == '') {
|
|
return null;
|
|
}
|
|
|
|
$notificationArray = [
|
|
'notification' => session('notification'),
|
|
'type' => session('notificationType') ?? '',
|
|
'eventId' => session('eventId') ?? '',
|
|
];
|
|
|
|
session(['notification' => '']);
|
|
session(['notificationType' => '']);
|
|
session(['eventId' => '']);
|
|
|
|
return $notificationArray;
|
|
}
|
|
|
|
/**
|
|
* Marks a notification (or 'all') read for the CURRENT (session) user.
|
|
*
|
|
* JSON-RPC entry point: derives the user from the session so a caller
|
|
* cannot mark another user's notifications read.
|
|
*
|
|
* @param int|string $id A notification id, or 'all'
|
|
* @return bool True on success
|
|
*
|
|
* @api
|
|
*/
|
|
public function markRead($id): bool
|
|
{
|
|
return $this->markNotificationRead($id, session('userdata.id'));
|
|
}
|
|
|
|
/**
|
|
* Not exposed via JSON-RPC (accepts an arbitrary $userId). Use markRead().
|
|
*/
|
|
public function markNotificationRead($id, $userId): bool
|
|
{
|
|
if ($id == 'all') {
|
|
return $this->notificationsRepo->markAllNotificationRead($userId);
|
|
}
|
|
|
|
// Scope the update by user so a caller cannot mark another user's notification read.
|
|
return $this->notificationsRepo->markNotificationRead($id, $userId);
|
|
}
|
|
|
|
/**
|
|
* Flip a previously-read notification back to unread for the authenticated
|
|
* (session) user. Powers the swipe-to-mark-unread inbox gesture on mobile —
|
|
* symmetric to markRead() so users can re-surface something they tapped open
|
|
* by accident.
|
|
*
|
|
* Session-scoped: the row is matched on (id, session user), so a caller
|
|
* cannot flip another user's notification unread by guessing its id. (The
|
|
* id is a global sequence, so an unscoped update would be an IDOR.)
|
|
*
|
|
* @param int $id The notification id to mark unread
|
|
* @return bool True on success
|
|
*
|
|
* @api
|
|
*/
|
|
public function markNotificationUnread(int $id): bool
|
|
{
|
|
$userId = (int) session('userdata.id');
|
|
if ($id <= 0 || $userId === 0) {
|
|
return false;
|
|
}
|
|
|
|
return $this->notificationsRepo->markNotificationUnread($id, $userId);
|
|
}
|
|
|
|
/**
|
|
* Unread notification count for the authenticated user. Mobile uses
|
|
* this for the app-icon badge and the inbox tab unread dot. Cheap
|
|
* because the (userId, read) composite index on zp_notifications
|
|
* makes it a fast count.
|
|
*
|
|
* @api
|
|
*/
|
|
public function getUnreadCount(): int
|
|
{
|
|
$userId = (int) session('userdata.id');
|
|
if ($userId === 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Delegated to the Repository — Service stores the raw DbCore
|
|
// (no query-builder helpers), Repository stores the resolved
|
|
// Illuminate ConnectionInterface. Following the established
|
|
// pattern that all SQL lives in the Repository layer.
|
|
return $this->notificationsRepo->getUnreadCount($userId);
|
|
}
|
|
|
|
/**
|
|
* Inbox listing for the authenticated (session) user. Mobile's inbox tab
|
|
* calls this with {} and gets only its own notifications (newest first).
|
|
*
|
|
* Session-scoped on purpose: getAllNotifications() takes an arbitrary
|
|
* $userId and is deliberately NOT @api-exposed (it would let a caller read
|
|
* another user's inbox). This wrapper derives the user from the session,
|
|
* exactly like getUnreadCount()/markRead() do, so there is nothing to gate.
|
|
*
|
|
* (Mark-all-read is already covered by markRead('all').)
|
|
*
|
|
* Pagination is clamped and the repo's arbitrary column=>value filter
|
|
* passthrough is intentionally NOT exposed here — an @api caller gets a
|
|
* bounded page of its OWN rows only (no caller-supplied WHERE columns, no
|
|
* unbounded limit).
|
|
*
|
|
* @param int $showNewOnly 1 = only unread notifications, 0 = all
|
|
* @param int $limitStart Offset for paging (clamped to >= 0)
|
|
* @param int $limitEnd Page size (clamped to 1..100)
|
|
* @return array<int, array<string, mixed>> The session user's notifications, or [] when unauthenticated
|
|
*
|
|
* @api
|
|
*/
|
|
public function getInbox(int $showNewOnly = 0, int $limitStart = 0, int $limitEnd = 50): array
|
|
{
|
|
$userId = (int) session('userdata.id');
|
|
if ($userId === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Clamp pagination so an @api caller cannot request an unbounded page.
|
|
$limitStart = max(0, $limitStart);
|
|
$limitEnd = min(max(1, $limitEnd), 100);
|
|
|
|
return $this->notificationsRepo->getAllNotifications($userId, (bool) $showNewOnly, $limitStart, $limitEnd) ?: [];
|
|
}
|
|
|
|
/**
|
|
* Register a mobile device's push token for the authenticated user.
|
|
* Mobile calls this on every login (idempotent). The push fields
|
|
* live directly on the bearer's zp_access_tokens row, so:
|
|
* - logout / token revoke deletes the push registration too
|
|
* (no orphan rows, no prune cron needed)
|
|
* - re-login auto-creates a fresh row that will be updated on the
|
|
* next registerPushToken call
|
|
*
|
|
* Per [[feedback-mobile-owns-explicit-rpc-params]] convention,
|
|
* userId is resolved server-side from session — we don't accept it
|
|
* from the client (a stolen bearer shouldn't be able to register
|
|
* push tokens on someone else's account).
|
|
*
|
|
* Provider:
|
|
* - 'fcm': raw Firebase Cloud Messaging registration token.
|
|
* Dispatched direct to FCM HTTP v1.
|
|
*
|
|
* No token-format validation. Bad tokens are caught at send-time
|
|
* by FCM (UNREGISTERED / INVALID_ARGUMENT) and we mark
|
|
* push_invalidated_at then. Pre-validating here would just shift a
|
|
* small class of failures earlier without saving any work.
|
|
*
|
|
* The push_provider column on zp_access_tokens is retained for
|
|
* forward compatibility, but only 'fcm' is accepted today.
|
|
*
|
|
* @param string $token FCM registration token
|
|
* @param string $platform 'ios' or 'android'
|
|
* @param string|null $deviceName Ignored — kept for backwards-
|
|
* compat with mobile clients that
|
|
* still send it; the device name
|
|
* lives on zp_access_tokens.name
|
|
* already (set at login time)
|
|
* @param string $provider Must be 'fcm' (default 'fcm')
|
|
*
|
|
* @api
|
|
*/
|
|
public function registerPushToken(string $token, string $platform, ?string $deviceName = null, string $provider = 'fcm'): bool
|
|
{
|
|
$userId = (int) session('userdata.id');
|
|
if ($userId === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (! in_array($platform, ['ios', 'android'], true)) {
|
|
return false;
|
|
}
|
|
if ($provider !== 'fcm') {
|
|
return false;
|
|
}
|
|
if ($token === '') {
|
|
return false;
|
|
}
|
|
|
|
$accessTokenId = $this->resolveCurrentAccessTokenId($userId);
|
|
if ($accessTokenId === null) {
|
|
return false;
|
|
}
|
|
|
|
return \Illuminate\Support\Facades\DB::table('zp_access_tokens')
|
|
->where('id', $accessTokenId)
|
|
->update([
|
|
'push_token' => $token,
|
|
'push_platform' => $platform,
|
|
'push_provider' => $provider,
|
|
'push_token_updated_at' => now(),
|
|
'push_invalidated_at' => null,
|
|
]) > 0;
|
|
}
|
|
|
|
/**
|
|
* Unregister this device's push token (called on mobile logout
|
|
* BEFORE the bearer is cleared, so the access token row is still
|
|
* resolvable). Soft-delete via push_invalidated_at — preserves the
|
|
* access-token row itself for the rest of the logout sequence.
|
|
*
|
|
* @param string $token The push token being unregistered. Kept
|
|
* for backwards-compat with mobile clients
|
|
* that pass it; we only need the bearer to
|
|
* identify the row, but a mismatch between
|
|
* passed-in token and stored token signals
|
|
* a race we should ignore (return true
|
|
* either way so logout doesn't fail).
|
|
*
|
|
* @api
|
|
*/
|
|
public function unregisterPushToken(string $token): bool
|
|
{
|
|
$userId = (int) session('userdata.id');
|
|
if ($userId === 0) {
|
|
return false;
|
|
}
|
|
|
|
$accessTokenId = $this->resolveCurrentAccessTokenId($userId);
|
|
if ($accessTokenId === null) {
|
|
return false;
|
|
}
|
|
|
|
\Illuminate\Support\Facades\DB::table('zp_access_tokens')
|
|
->where('id', $accessTokenId)
|
|
->update(['push_invalidated_at' => now()]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resolve the zp_access_tokens.id for the bearer that authenticated
|
|
* the current request. Tries Sanctum's currentAccessToken() first;
|
|
* falls back to the most-recently-used row for the user when the
|
|
* Sanctum guard isn't bound (legacy session-only auth path).
|
|
*
|
|
* Returns null only when no rows exist for the user — at that point
|
|
* the caller can't register push without a row to attach it to.
|
|
*/
|
|
private function resolveCurrentAccessTokenId(int $userId): ?int
|
|
{
|
|
try {
|
|
$user = auth()->user();
|
|
if ($user !== null && method_exists($user, 'currentAccessToken')) {
|
|
$current = $user->currentAccessToken();
|
|
if ($current !== null && isset($current->id)) {
|
|
return (int) $current->id;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Sanctum guard not bound or user model doesn't support
|
|
// currentAccessToken — fall through to the lookup below.
|
|
}
|
|
|
|
$row = \Illuminate\Support\Facades\DB::table('zp_access_tokens')
|
|
->where('tokenable_id', $userId)
|
|
->orderByDesc('last_used_at')
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
return $row !== null ? (int) $row->id : null;
|
|
}
|
|
|
|
/**
|
|
* @throws BindingResolutionException
|
|
*
|
|
* @api
|
|
*/
|
|
public function processMentions(string $content, string $module, int $moduleId, int $authorId, string $url): void
|
|
{
|
|
|
|
$dom = new DOMDocument;
|
|
|
|
// Content may not be well formatted. Suppress warnings.
|
|
@$dom->loadHTML($content);
|
|
$links = $dom->getElementsByTagName('a');
|
|
|
|
$author = $this->userRepository->getUser($authorId);
|
|
if ($author === false) {
|
|
return;
|
|
}
|
|
|
|
$authorName = htmlspecialchars(NameSanitizer::clean($author['firstname'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
|
|
for ($i = 0; $i < $links->count(); $i++) {
|
|
$taggedUser = $links->item($i)->getAttribute('data-tagged-user-id');
|
|
|
|
if ($taggedUser !== '' && is_numeric($taggedUser)) {
|
|
// Check if user was mentioned before
|
|
$userMentions = $this->getAllNotifications(
|
|
$taggedUser,
|
|
false,
|
|
0,
|
|
10,
|
|
['type' => 'mention', 'module' => $module, 'moduleId' => $moduleId]
|
|
);
|
|
|
|
if ($userMentions === false || (is_array($userMentions) && count($userMentions) == 0)) {
|
|
$notification = [
|
|
'userId' => $taggedUser,
|
|
'read' => '0',
|
|
'type' => 'mention',
|
|
'module' => $module,
|
|
'moduleId' => $moduleId,
|
|
'message' => sprintf($this->language->__('text.x_mentioned_you'), $authorName),
|
|
'datetime' => date('Y-m-d H:i:s'),
|
|
'url' => $url,
|
|
'authorId' => $authorId,
|
|
];
|
|
|
|
$this->addNotifications([$notification]);
|
|
|
|
// send email
|
|
$mailer = app()->make(MailerCore::class);
|
|
$mailer->setContext('notify_project_users');
|
|
|
|
$subject = sprintf($this->language->__('text.x_mentioned_you'), $authorName);
|
|
$mailer->setSubject($subject);
|
|
|
|
$emailMessage = $subject.' <a href="'.$url.'">'.$this->language->__('text.click_here').'</a>';
|
|
$mailer->setHtml($emailMessage);
|
|
|
|
$taggedUserObject = $this->userRepository->getUser($taggedUser);
|
|
if (isset($taggedUserObject['username'])) {
|
|
$mailer->sendMail([$taggedUserObject['username']], 'Leantime');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|