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 +238 -26 2.0.4 → 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 ];
@@ -352,10 +407,10 @@
352 407 }
353 408
354 409 public function getActivities($id, $data)
355 410 {
356 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
357 - $page = isset($data['page']) ? $data['page'] : 1;
411 + $per_page = Sanitize::perPage($data['per_page'] ?? 40);
412 + $page = max(1, (int) ($data['page'] ?? 1));
358 413 $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
359 414 ->orderBy('id', 'DESC')
360 415 ->paginate($per_page, ['*'], 'page', $page);
361 416
@@ -372,14 +427,29 @@
372 427
373 428 return $isAlreadyMember ?? false;
374 429 }
375 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 + */
376 437 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
377 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 +
378 447 $board = Board::find($boardId);
448 + $boardMember = User::find($memberId);
379 449
380 - if (!$board) {
381 - return false;
450 + if (!$board || !$boardMember) {
451 + return null;
382 452 }
383 453 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
384 454 if($isAlreadyMember) {
385 455 return false;
@@ -399,9 +469,8 @@
399 469 'settings' => maybe_serialize($settings),
400 470 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
401 471 ]
402 472 );
403 - $boardMember = User::find($memberId);
404 473 if(!$isViewerOnly) {
405 474 do_action('fluent_boards/board_member_added', $boardId, $boardMember);
406 475 } else {
407 476 do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
@@ -522,20 +591,31 @@
522 591
523 592 /**
524 593 * Change or clear the board background.
525 594 *
526 - * @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
527 600 * @return array|string
601 + * @throws \Exception
528 602 */
529 603 public function setBoardBackground($backgroundData, $board_id)
530 604 {
531 - $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 +
532 612 $oldBackground = $board->background;
533 613
534 614 if (!empty($backgroundData['reset'])) {
535 615 $board->background = '';
536 616 $board->save();
537 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
617 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
538 618
539 619 return $board->background;
540 620 }
541 621
@@ -543,26 +623,34 @@
543 623 if (!is_array($background)) {
544 624 $background = [];
545 625 }
546 626
547 - // if board background has color
548 - 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'];
549 645 $background['color'] = $backgroundData['color'];
550 646 $background['image_url'] = null;
551 647 $background['is_image'] = false;
552 648 }
553 649
554 - // if board background has image
555 - if (isset($backgroundData['image_url'])) {
556 - $background['image_url'] = $backgroundData['image_url'];
557 - $background['is_image'] = true;
558 - $background['color'] = null;
559 - }
560 - $background['id'] = $backgroundData['id'];
561 -
562 650 $board->background = $background;
563 651 $board->save();
564 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
652 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
565 653
566 654 return $board->background;
567 655 }
568 656
@@ -910,11 +998,35 @@
910 998 ->where('key', Constant::BOARD_INVITATION)
911 999 ->get();
912 1000 }
913 1001
914 - 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)
915 1009 {
916 - 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();
917 1029 }
918 1030
919 1031 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
920 1032 {
@@ -1083,9 +1195,9 @@
1083 1195 return (string) $timestamp;
1084 1196 }
1085 1197
1086 1198 /**
1087 - * 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.
1088 1200 *
1089 1201 * @param int $associatedId CRM contact/subscriber id.
1090 1202 * @param int|null $userId WordPress user id used for board access checks.
1091 1203 * @return \FluentBoards\Framework\Database\Orm\Collection|array
@@ -1098,18 +1210,40 @@
1098 1210 if (!$associatedId || !$userId) {
1099 1211 return [];
1100 1212 }
1101 1213
1102 - $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)
1103 1226 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1104 1227 ->whereIn('key', [
1105 1228 Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1106 1229 self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1107 1230 ])
1108 - ->pluck('object_id');
1231 + ->distinct()
1232 + ->pluck('object_id')
1233 + ->toArray();
1109 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 +
1110 1244 $boards = Board::query()
1111 - ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
1245 + ->whereIn('id', $boardIds)
1112 1246 ->whereNull('archived_at')
1113 1247 ->byAccessUser($userId)
1114 1248 ->withCount('completedTasks')
1115 1249 ->with(['stages', 'users'])
@@ -1143,8 +1277,86 @@
1143 1277
1144 1278 $this->setCurrentUserPreferencesOnBoardCreate($board);
1145 1279
1146 1280 return $board;
1281 + }
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;
1147 1359 }
1148 1360
1149 1361 public function archiveBoard($boardId)
1150 1362 {