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 +161 -7 2.0.15 → 2.2.0 View file →
@@ -1,8 +1,9 @@
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;
6 7 use FluentBoards\App\Models\Attachment;
7 8 use FluentBoards\App\Models\Board;
8 9 use FluentBoards\App\Models\Comment;
@@ -15,8 +16,9 @@
15 16 use FluentBoards\App\Models\TaskMeta;
16 17 use FluentBoards\App\Models\User;
17 18 use FluentBoards\App\Services\Libs\FileSystem;
18 19 use FluentBoards\App\Services\DescriptionMarkdownConverter;
20 +use FluentBoardsPro\App\Models\CustomField;
19 21
20 22 class BoardService
21 23 {
22 24 private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
@@ -46,8 +48,39 @@
46 48
47 49 // Delete time tracking records for all tasks in the board
48 50 (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
49 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 +
50 83 Task::whereIn('id', $allTaskIdsInBoard)->delete();
51 84
52 85 // delete all activities
53 86 Activity::whereIn('object_id', $allTaskIdsInBoard)->where('object_type', Constant::ACTIVITY_TASK)->delete();
@@ -95,8 +128,29 @@
95 128 $board->delete();
96 129 FileSystem::deleteDir('board_'.$boardId);
97 130 }
98 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 +
99 153 public function fetchBoardMeta($boardId)
100 154 {
101 155 $boardMeta = Meta::where('object_id', $boardId)
102 156 ->where('object_type', 'board')
@@ -150,9 +204,9 @@
150 204 {
151 205 $boardData = [
152 206 'title' => $boardData['title'],
153 207 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
154 - 'description' => DescriptionMarkdownConverter::normalize($boardData['description']),
208 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
155 209 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
156 210 'background' => isset($boardData['background']) ? $boardData['background'] : '',
157 211 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
158 212 ];
@@ -353,10 +407,10 @@
353 407 }
354 408
355 409 public function getActivities($id, $data)
356 410 {
357 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
358 - $page = isset($data['page']) ? $data['page'] : 1;
411 + $per_page = Sanitize::perPage($data['per_page'] ?? 40);
412 + $page = max(1, (int) ($data['page'] ?? 1));
359 413 $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
360 414 ->orderBy('id', 'DESC')
361 415 ->paginate($per_page, ['*'], 'page', $page);
362 416
@@ -1141,9 +1195,9 @@
1141 1195 return (string) $timestamp;
1142 1196 }
1143 1197
1144 1198 /**
1145 - * 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.
1146 1200 *
1147 1201 * @param int $associatedId CRM contact/subscriber id.
1148 1202 * @param int|null $userId WordPress user id used for board access checks.
1149 1203 * @return \FluentBoards\Framework\Database\Orm\Collection|array
@@ -1156,18 +1210,40 @@
1156 1210 if (!$associatedId || !$userId) {
1157 1211 return [];
1158 1212 }
1159 1213
1160 - $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)
1161 1226 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1162 1227 ->whereIn('key', [
1163 1228 Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1164 1229 self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1165 1230 ])
1166 - ->pluck('object_id');
1231 + ->distinct()
1232 + ->pluck('object_id')
1233 + ->toArray();
1167 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 +
1168 1244 $boards = Board::query()
1169 - ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
1245 + ->whereIn('id', $boardIds)
1170 1246 ->whereNull('archived_at')
1171 1247 ->byAccessUser($userId)
1172 1248 ->withCount('completedTasks')
1173 1249 ->with(['stages', 'users'])
@@ -1201,8 +1277,86 @@
1201 1277
1202 1278 $this->setCurrentUserPreferencesOnBoardCreate($board);
1203 1279
1204 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;
1205 1359 }
1206 1360
1207 1361 public function archiveBoard($boardId)
1208 1362 {