PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Services/BoardService.php +260 -33 2.0.0 → 2.2.0 View file →
@@ -1,9 +1,11 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\Hooks\Handlers\FileHandler;
5 6 use FluentBoards\App\Models\Activity;
7 +use FluentBoards\App\Models\Attachment;
6 8 use FluentBoards\App\Models\Board;
7 9 use FluentBoards\App\Models\Comment;
8 10 use FluentBoards\App\Models\Folder;
9 11 use FluentBoards\App\Models\Label;
@@ -14,8 +16,9 @@
14 16 use FluentBoards\App\Models\TaskMeta;
15 17 use FluentBoards\App\Models\User;
16 18 use FluentBoards\App\Services\Libs\FileSystem;
17 19 use FluentBoards\App\Services\DescriptionMarkdownConverter;
20 +use FluentBoardsPro\App\Models\CustomField;
18 21
19 22 class BoardService
20 23 {
21 24 private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
@@ -45,8 +48,39 @@
45 48
46 49 // Delete time tracking records for all tasks in the board
47 50 (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
48 51
52 + // Task files, task description images and the background image are keyed by
53 + // task or board id. Their local files go with the board_<id> directory below;
54 + // files on a cloud driver are queued for deletion once the rows are gone.
55 + $boardAttachments = function () use ($allTaskIdsInBoard, $boardId) {
56 + // One group, so filters chained on the result apply to both branches.
57 + return Attachment::where(function ($owned) use ($allTaskIdsInBoard, $boardId) {
58 + $owned->where(function ($query) use ($allTaskIdsInBoard) {
59 + $query->whereIn('object_id', $allTaskIdsInBoard)
60 + ->whereIn('object_type', [Constant::TASK_ATTACHMENT, Constant::TASK_DESCRIPTION]);
61 + })->orWhere(function ($query) use ($boardId) {
62 + $query->where('object_id', $boardId)
63 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE);
64 + });
65 + });
66 + };
67 + $remoteFiles = $boardAttachments()
68 + ->where('attachment_type', '!=', 'url')
69 + ->whereNotNull('driver')
70 + ->where('driver', '!=', 'local')
71 + ->get(['file_path', 'driver'])
72 + ->filter(function ($attachment) {
73 + return !empty($attachment->file_path);
74 + })
75 + ->map(function ($attachment) {
76 + return ['file_path' => $attachment->file_path, 'driver' => $attachment->driver];
77 + })
78 + ->values()
79 + ->toArray();
80 + $boardAttachments()->delete();
81 + $this->queueRemoteAttachmentFileDeletes($remoteFiles);
82 +
49 83 Task::whereIn('id', $allTaskIdsInBoard)->delete();
50 84
51 85 // delete all activities
52 86 Activity::whereIn('object_id', $allTaskIdsInBoard)->where('object_type', Constant::ACTIVITY_TASK)->delete();
@@ -94,8 +128,29 @@
94 128 $board->delete();
95 129 FileSystem::deleteDir('board_'.$boardId);
96 130 }
97 131
132 + /**
133 + * Hand remote attachment files to the storage add-on in the background.
134 + *
135 + * A board can hold hundreds of cloud files, one HTTP request each, so they
136 + * are deleted by Action Scheduler in batches instead of inside this request.
137 + * Without Action Scheduler they are deleted right away.
138 + *
139 + * @param array $files List of ['file_path' => string, 'driver' => string].
140 + * @return void
141 + */
142 + private function queueRemoteAttachmentFileDeletes($files)
143 + {
144 + foreach (array_chunk($files, 50) as $batch) {
145 + if (function_exists('as_enqueue_async_action')) {
146 + as_enqueue_async_action('fluent_boards/delete_remote_attachment_files', [$batch], 'fluent-boards');
147 + } else {
148 + (new FileHandler())->deleteRemoteAttachmentFiles($batch);
149 + }
150 + }
151 + }
152 +
98 153 public function fetchBoardMeta($boardId)
99 154 {
100 155 $boardMeta = Meta::where('object_id', $boardId)
101 156 ->where('object_type', 'board')
@@ -149,9 +204,9 @@
149 204 {
150 205 $boardData = [
151 206 'title' => $boardData['title'],
152 207 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
153 - 'description' => DescriptionMarkdownConverter::normalize($boardData['description']),
208 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
154 209 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
155 210 'background' => isset($boardData['background']) ? $boardData['background'] : '',
156 211 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
157 212 ];
@@ -164,12 +219,23 @@
164 219
165 220 return $board;
166 221 }
167 222
223 + /**
224 + * Attach a user-owned board to its creator with Board Admin preferences.
225 + *
226 + * @param Board $board
227 + * @return void
228 + */
168 229 public function setCurrentUserPreferencesOnBoardCreate($board)
169 230 {
231 + $creatorId = absint($board->created_by);
232 + if (!$creatorId) {
233 + return;
234 + }
235 +
170 236 $board->users()->attach(
171 - $board->created_by,
237 + $creatorId,
172 238 [
173 239 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
174 240 'settings' => maybe_serialize([
175 241 Constant::IS_BOARD_ADMIN => true
@@ -341,10 +407,10 @@
341 407 }
342 408
343 409 public function getActivities($id, $data)
344 410 {
345 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
346 - $page = isset($data['page']) ? $data['page'] : 1;
411 + $per_page = Sanitize::perPage($data['per_page'] ?? 40);
412 + $page = max(1, (int) ($data['page'] ?? 1));
347 413 $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
348 414 ->orderBy('id', 'DESC')
349 415 ->paginate($per_page, ['*'], 'page', $page);
350 416
@@ -361,14 +427,29 @@
361 427
362 428 return $isAlreadyMember ?? false;
363 429 }
364 430
431 + /**
432 + * Add a WordPress user to a board.
433 + *
434 + * @return User|false|null User on success, false for an existing relation,
435 + * or null when the board/user does not exist.
436 + */
365 437 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
366 438 {
439 + $boardId = intval($boardId);
440 + $memberId = intval($memberId);
441 + $isViewerOnly = sanitize_text_field((string)$isViewerOnly);
442 +
443 + if ($boardId <= 0 || $memberId <= 0) {
444 + return null;
445 + }
446 +
367 447 $board = Board::find($boardId);
448 + $boardMember = User::find($memberId);
368 449
369 - if (!$board) {
370 - return false;
450 + if (!$board || !$boardMember) {
451 + return null;
371 452 }
372 453 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
373 454 if($isAlreadyMember) {
374 455 return false;
@@ -388,9 +469,8 @@
388 469 'settings' => maybe_serialize($settings),
389 470 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
390 471 ]
391 472 );
392 - $boardMember = User::find($memberId);
393 473 if(!$isViewerOnly) {
394 474 do_action('fluent_boards/board_member_added', $boardId, $boardMember);
395 475 } else {
396 476 do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
@@ -511,20 +591,31 @@
511 591
512 592 /**
513 593 * Change or clear the board background.
514 594 *
515 - * @param mixed $backgroundData
595 + * Image attachments must belong to the target board and use the board
596 + * background attachment type before their identifiers can be persisted.
597 + *
598 + * @param array $backgroundData
599 + * @param int $board_id
516 600 * @return array|string
601 + * @throws \Exception
517 602 */
518 603 public function setBoardBackground($backgroundData, $board_id)
519 604 {
520 - $board = Board::find($board_id);
605 + $boardId = absint($board_id);
606 + $board = Board::find($boardId);
607 +
608 + if (!$board) {
609 + throw new \Exception(esc_html__('Board not found.', 'fluent-boards'));
610 + }
611 +
521 612 $oldBackground = $board->background;
522 613
523 614 if (!empty($backgroundData['reset'])) {
524 615 $board->background = '';
525 616 $board->save();
526 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
617 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
527 618
528 619 return $board->background;
529 620 }
530 621
@@ -532,26 +623,34 @@
532 623 if (!is_array($background)) {
533 624 $background = [];
534 625 }
535 626
536 - // if board background has color
537 - if (isset($backgroundData['color'])) {
627 + // Resolve image metadata from the board-owned attachment, never from the client URL.
628 + if (isset($backgroundData['image_url'])) {
629 + $attachmentId = absint($backgroundData['id'] ?? 0);
630 + $attachment = Attachment::where('id', $attachmentId)
631 + ->where('object_id', $boardId)
632 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE)
633 + ->first();
634 +
635 + if (!$attachment) {
636 + throw new \Exception(esc_html__('Background image not found.', 'fluent-boards'));
637 + }
638 +
639 + $background['id'] = (int) $attachment->id;
640 + $background['image_url'] = (new CommentService())->createPublicUrl($attachment, $boardId);
641 + $background['is_image'] = true;
642 + $background['color'] = null;
643 + } elseif (isset($backgroundData['color'])) {
644 + $background['id'] = $backgroundData['id'];
538 645 $background['color'] = $backgroundData['color'];
539 646 $background['image_url'] = null;
540 647 $background['is_image'] = false;
541 648 }
542 649
543 - // if board background has image
544 - if (isset($backgroundData['image_url'])) {
545 - $background['image_url'] = $backgroundData['image_url'];
546 - $background['is_image'] = true;
547 - $background['color'] = null;
548 - }
549 - $background['id'] = $backgroundData['id'];
550 -
551 650 $board->background = $background;
552 651 $board->save();
553 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
652 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
554 653
555 654 return $board->background;
556 655 }
557 656
@@ -764,9 +863,15 @@
764 863
765 864 $this->updateRecentBoardCheckMeta();
766 865 }
767 866
768 - return Board::whereIn('id', $recentBoardIds)->excludeTemplates()->withCount('completedTasks')->with(['stages', 'users'])->get();
867 + return Board::whereIn('id', $recentBoardIds)
868 + ->whereNull('archived_at')
869 + ->excludeTemplates()
870 + ->availableInCurrentInstall()
871 + ->withCount('completedTasks')
872 + ->with(['stages', 'users'])
873 + ->get();
769 874 }
770 875
771 876 public function getRecentBoardCheckMeta($userId = null){
772 877 if (!$userId) {
@@ -893,11 +998,35 @@
893 998 ->where('key', Constant::BOARD_INVITATION)
894 999 ->get();
895 1000 }
896 1001
897 - public function deleteInvitation($invitationId)
1002 + /**
1003 + * Delete an invitation only when it belongs to the supplied board.
1004 + *
1005 + * The optional second argument lets older Pro releases receive a controlled
1006 + * error instead of reporting a successful deletion that never happened.
1007 + */
1008 + public function deleteInvitation($boardId, $invitationId = null)
898 1009 {
899 - Meta::findOrFail($invitationId)->delete();
1010 + if ($invitationId === null) {
1011 + throw new \Exception(
1012 + __('A board ID is required to delete an invitation.', 'fluent-boards')
1013 + );
1014 + }
1015 +
1016 + $boardId = intval($boardId);
1017 + $invitationId = intval($invitationId);
1018 +
1019 + if ($boardId <= 0 || $invitationId <= 0) {
1020 + return false;
1021 + }
1022 +
1023 + return (bool) Meta::query()
1024 + ->where('id', $invitationId)
1025 + ->where('object_id', $boardId)
1026 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1027 + ->where('key', Constant::BOARD_INVITATION)
1028 + ->delete();
900 1029 }
901 1030
902 1031 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
903 1032 {
@@ -1066,9 +1195,9 @@
1066 1195 return (string) $timestamp;
1067 1196 }
1068 1197
1069 1198 /**
1070 - * Get CRM-associated boards that the current user can access.
1199 + * Get CRM-associated boards from non-archived task links and board-level contact metadata.
1071 1200 *
1072 1201 * @param int $associatedId CRM contact/subscriber id.
1073 1202 * @param int|null $userId WordPress user id used for board access checks.
1074 1203 * @return \FluentBoards\Framework\Database\Orm\Collection|array
@@ -1081,18 +1210,40 @@
1081 1210 if (!$associatedId || !$userId) {
1082 1211 return [];
1083 1212 }
1084 1213
1085 - $boardIds = Meta::query()->where('value', $associatedId)
1214 + $taskBoardIds = Task::query()
1215 + ->select('board_id')
1216 + ->where('crm_contact_id', $associatedId)
1217 + ->whereNull('archived_at')
1218 + ->whereNotNull('board_id')
1219 + ->distinct()
1220 + ->pluck('board_id')
1221 + ->toArray();
1222 +
1223 + $metaBoardIds = Meta::query()
1224 + ->select('object_id')
1225 + ->where('value', $associatedId)
1086 1226 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1087 1227 ->whereIn('key', [
1088 1228 Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1089 1229 self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1090 1230 ])
1091 - ->pluck('object_id');
1231 + ->distinct()
1232 + ->pluck('object_id')
1233 + ->toArray();
1092 1234
1235 + $boardIds = array_values(array_unique(array_filter(array_map('intval', array_merge(
1236 + $taskBoardIds,
1237 + $metaBoardIds
1238 + )))));
1239 +
1240 + if (!$boardIds) {
1241 + return [];
1242 + }
1243 +
1093 1244 $boards = Board::query()
1094 - ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
1245 + ->whereIn('id', $boardIds)
1095 1246 ->whereNull('archived_at')
1096 1247 ->byAccessUser($userId)
1097 1248 ->withCount('completedTasks')
1098 1249 ->with(['stages', 'users'])
@@ -1128,8 +1279,86 @@
1128 1279
1129 1280 return $board;
1130 1281 }
1131 1282
1283 + /**
1284 + * Copy board member relations and their role preferences to a duplicated board.
1285 + */
1286 + public function copyMembersOfBoard($sourceBoardId, Board $newBoard)
1287 + {
1288 + $existingMemberIds = Relation::where('object_id', $newBoard->id)
1289 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1290 + ->pluck('foreign_id')
1291 + ->toArray();
1292 +
1293 + $sourceMembers = Relation::where('object_id', $sourceBoardId)
1294 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1295 + ->get();
1296 +
1297 + foreach ($sourceMembers as $sourceMember) {
1298 + if (in_array($sourceMember->foreign_id, $existingMemberIds)) {
1299 + continue;
1300 + }
1301 +
1302 + Relation::create([
1303 + 'object_id' => $newBoard->id,
1304 + 'foreign_id' => $sourceMember->foreign_id,
1305 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
1306 + 'settings' => $sourceMember->settings,
1307 + 'preferences' => $sourceMember->preferences
1308 + ]);
1309 +
1310 + $boardMember = User::find($sourceMember->foreign_id);
1311 + $settings = is_array($sourceMember->settings) ? $sourceMember->settings : [];
1312 +
1313 + if ($boardMember) {
1314 + if (!empty($settings['is_viewer_only'])) {
1315 + do_action('fluent_boards/board_viewer_added', $newBoard->id, $boardMember);
1316 + } else {
1317 + do_action('fluent_boards/board_member_added', $newBoard->id, $boardMember);
1318 + }
1319 +
1320 + if (!empty($settings[Constant::IS_BOARD_ADMIN])) {
1321 + do_action('fluent_boards/board_admin_added', $newBoard->id, $sourceMember->foreign_id);
1322 + }
1323 + }
1324 +
1325 + $existingMemberIds[] = $sourceMember->foreign_id;
1326 + }
1327 + }
1328 +
1329 + /**
1330 + * Copy Pro custom field definitions and return an old-to-new ID map.
1331 + */
1332 + public function copyCustomFieldsOfBoard($fromBoardId, $board)
1333 + {
1334 + if (!defined('FLUENT_BOARDS_PRO') || !class_exists(CustomField::class)) {
1335 + return [];
1336 + }
1337 +
1338 + $fromBoardId = absint($fromBoardId);
1339 +
1340 + if (!$fromBoardId || !$board) {
1341 + return [];
1342 + }
1343 +
1344 + $customFieldMap = [];
1345 + $customFields = CustomField::where('board_id', $fromBoardId)
1346 + ->whereNull('archived_at')
1347 + ->orderBy('position', 'asc')
1348 + ->get();
1349 +
1350 + foreach ($customFields as $customField) {
1351 + $newCustomField = $customField->replicate();
1352 + $newCustomField->board_id = $board->id;
1353 + $newCustomField->save();
1354 +
1355 + $customFieldMap[(int) $customField->id] = $newCustomField->id;
1356 + }
1357 +
1358 + return $customFieldMap;
1359 + }
1360 +
1132 1361 public function archiveBoard($boardId)
1133 1362 {
1134 1363 $board = Board::findOrFail($boardId);
1135 1364 $board->archived_at = current_time('mysql');
@@ -1209,13 +1438,11 @@
1209 1438 * @return array{all: int, pinned: int, archived: int}
1210 1439 */
1211 1440 public function getBoardCounts($userId)
1212 1441 {
1213 - $baseQuery = Board::byAccessUser($userId)->excludeTemplates();
1214 -
1215 - if (!defined('FLUENT_ROADMAP')) {
1216 - $baseQuery = $baseQuery->where('type', 'to-do');
1217 - }
1442 + $baseQuery = Board::byAccessUser($userId)
1443 + ->excludeTemplates()
1444 + ->availableInCurrentInstall();
1218 1445
1219 1446 $counts = [
1220 1447 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1221 1448 'pinned' => 0,