| @@ -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 | { |