@onahhas/hello-dev 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +149 -11
  2. package/backend/Controllers/AccountController.cs +100 -0
  3. package/backend/Controllers/ActivityController.cs +44 -0
  4. package/backend/Controllers/AuthController.cs +127 -0
  5. package/backend/Controllers/LookupController.cs +46 -0
  6. package/backend/Controllers/TasksController.cs +652 -0
  7. package/backend/Controllers/UsersController.cs +181 -0
  8. package/backend/Data/AppDbContext.cs +93 -0
  9. package/backend/Data/DbSeeder.cs +122 -0
  10. package/backend/DevTasks.Api.csproj +13 -0
  11. package/backend/Dtos/ActivityDtos.cs +12 -0
  12. package/backend/Dtos/AuthDtos.cs +37 -0
  13. package/backend/Dtos/TaskDtos.cs +104 -0
  14. package/backend/Dtos/UserDtos.cs +29 -0
  15. package/backend/Enums/EditRequestStatus.cs +8 -0
  16. package/backend/Enums/TaskPriority.cs +8 -0
  17. package/backend/Enums/TaskState.cs +9 -0
  18. package/backend/Enums/TaskVisibility.cs +7 -0
  19. package/backend/Enums/UserRole.cs +7 -0
  20. package/backend/Extensions/ClaimsPrincipalExtensions.cs +23 -0
  21. package/backend/Models/ActivityLog.cs +12 -0
  22. package/backend/Models/AppUser.cs +17 -0
  23. package/backend/Models/TaskEditRequest.cs +31 -0
  24. package/backend/Models/TaskItem.cs +25 -0
  25. package/backend/Program.cs +138 -0
  26. package/backend/Properties/launchSettings.json +13 -0
  27. package/backend/Services/ActivityService.cs +28 -0
  28. package/backend/Services/PasswordHasher.cs +58 -0
  29. package/backend/Services/TokenService.cs +49 -0
  30. package/backend/appsettings.Development.json +10 -0
  31. package/backend/appsettings.json +24 -0
  32. package/frontend/index.html +12 -0
  33. package/frontend/package-lock.json +1769 -0
  34. package/frontend/package.json +23 -0
  35. package/frontend/src/App.tsx +40 -0
  36. package/frontend/src/api/http.ts +75 -0
  37. package/frontend/src/auth/AuthContext.tsx +101 -0
  38. package/frontend/src/components/EditRequestModal.tsx +139 -0
  39. package/frontend/src/components/EditRequestsPanel.tsx +94 -0
  40. package/frontend/src/components/Layout.tsx +76 -0
  41. package/frontend/src/components/PageHeader.tsx +21 -0
  42. package/frontend/src/components/ProtectedRoute.tsx +14 -0
  43. package/frontend/src/components/StatCard.tsx +15 -0
  44. package/frontend/src/components/TaskCard.tsx +83 -0
  45. package/frontend/src/components/TaskDetailsModal.tsx +45 -0
  46. package/frontend/src/components/TaskFilters.tsx +67 -0
  47. package/frontend/src/components/TaskModal.tsx +159 -0
  48. package/frontend/src/components/TaskTable.tsx +68 -0
  49. package/frontend/src/components/UserModal.tsx +124 -0
  50. package/frontend/src/main.tsx +19 -0
  51. package/frontend/src/pages/ActivityPage.tsx +37 -0
  52. package/frontend/src/pages/BoardPage.tsx +75 -0
  53. package/frontend/src/pages/CalendarPage.tsx +101 -0
  54. package/frontend/src/pages/DashboardPage.tsx +131 -0
  55. package/frontend/src/pages/LoginPage.tsx +69 -0
  56. package/frontend/src/pages/ProfilePage.tsx +111 -0
  57. package/frontend/src/pages/PublicTasksPage.tsx +99 -0
  58. package/frontend/src/pages/RegisterPage.tsx +80 -0
  59. package/frontend/src/pages/TasksPage.tsx +135 -0
  60. package/frontend/src/pages/UsersPage.tsx +86 -0
  61. package/frontend/src/styles.css +596 -0
  62. package/frontend/src/theme.tsx +49 -0
  63. package/frontend/src/types.ts +78 -0
  64. package/frontend/src/utils/date.ts +30 -0
  65. package/frontend/src/utils/labels.ts +3 -0
  66. package/frontend/src/vite-env.d.ts +1 -0
  67. package/frontend/tsconfig.json +21 -0
  68. package/frontend/vite.config.ts +15 -0
  69. package/package.json +22 -9
  70. package/index.js +0 -7
@@ -0,0 +1,652 @@
1
+ using DevTasks.Api.Data;
2
+ using DevTasks.Api.Dtos;
3
+ using DevTasks.Api.Enums;
4
+ using DevTasks.Api.Extensions;
5
+ using DevTasks.Api.Models;
6
+ using DevTasks.Api.Services;
7
+ using Microsoft.AspNetCore.Authorization;
8
+ using Microsoft.AspNetCore.Mvc;
9
+ using Microsoft.EntityFrameworkCore;
10
+
11
+ namespace DevTasks.Api.Controllers;
12
+
13
+ [ApiController]
14
+ [Route("api/[controller]")]
15
+ [Authorize]
16
+ public sealed class TasksController : ControllerBase
17
+ {
18
+ private readonly AppDbContext _db;
19
+ private readonly ActivityService _activity;
20
+
21
+ public TasksController(AppDbContext db, ActivityService activity)
22
+ {
23
+ _db = db;
24
+ _activity = activity;
25
+ }
26
+
27
+ [HttpGet("my")]
28
+ public async Task<ActionResult<IReadOnlyList<TaskResponse>>> GetMyTasks([FromQuery] TaskQuery query)
29
+ {
30
+ var userId = User.GetUserId();
31
+ var tasks = BaseQuery()
32
+ .Where(x => x.CreatedByUserId == userId || x.AssignedToUserId == userId);
33
+
34
+ tasks = ApplyFilters(tasks, query);
35
+ return Ok(await ToListAsync(tasks));
36
+ }
37
+
38
+ [HttpGet("public")]
39
+ public async Task<ActionResult<IReadOnlyList<TaskResponse>>> GetPublicTasks([FromQuery] TaskQuery query)
40
+ {
41
+ var tasks = BaseQuery().Where(x => x.Visibility == TaskVisibility.Public);
42
+ tasks = ApplyFilters(tasks, query);
43
+ return Ok(await ToListAsync(tasks));
44
+ }
45
+
46
+ [HttpGet("all")]
47
+ [Authorize(Roles = nameof(UserRole.Admin))]
48
+ public async Task<ActionResult<IReadOnlyList<TaskResponse>>> GetAllTasks([FromQuery] TaskQuery query)
49
+ {
50
+ var tasks = ApplyFilters(BaseQuery(), query);
51
+ return Ok(await ToListAsync(tasks));
52
+ }
53
+
54
+ [HttpGet("edit-requests/inbox")]
55
+ public async Task<ActionResult<IReadOnlyList<TaskEditRequestResponse>>> GetEditRequestInbox()
56
+ {
57
+ var userId = User.GetUserId();
58
+ var isAdmin = User.IsAdmin();
59
+
60
+ var requests = EditRequestBaseQuery()
61
+ .Where(x => isAdmin || x.TaskItem!.CreatedByUserId == userId)
62
+ .OrderByDescending(x => x.StatusOfRequest == EditRequestStatus.Pending)
63
+ .ThenByDescending(x => x.CreatedAt);
64
+
65
+ return Ok((await requests.ToListAsync()).Select(ToEditRequestResponse).ToList());
66
+ }
67
+
68
+ [HttpGet("edit-requests/mine")]
69
+ public async Task<ActionResult<IReadOnlyList<TaskEditRequestResponse>>> GetMyEditRequests()
70
+ {
71
+ var userId = User.GetUserId();
72
+
73
+ var requests = await EditRequestBaseQuery()
74
+ .Where(x => x.RequestedByUserId == userId)
75
+ .OrderByDescending(x => x.CreatedAt)
76
+ .ToListAsync();
77
+
78
+ return Ok(requests.Select(ToEditRequestResponse).ToList());
79
+ }
80
+
81
+ [HttpGet("{id:int}")]
82
+ public async Task<ActionResult<TaskResponse>> GetTask(int id)
83
+ {
84
+ var item = await BaseQuery().FirstOrDefaultAsync(x => x.Id == id);
85
+ if (item is null)
86
+ {
87
+ return NotFound(new { message = "Task was not found." });
88
+ }
89
+
90
+ var userId = User.GetUserId();
91
+ if (!CanRead(item, userId, User.IsAdmin()))
92
+ {
93
+ return Forbid();
94
+ }
95
+
96
+ return Ok(ToResponse(item));
97
+ }
98
+
99
+ [HttpPost]
100
+ public async Task<ActionResult<TaskResponse>> CreateTask(CreateTaskRequest request)
101
+ {
102
+ var validation = await ValidateTaskInputAsync(request.Title, request.AssignedToUserId);
103
+ if (validation is not null)
104
+ {
105
+ return BadRequest(new { message = validation });
106
+ }
107
+
108
+ var userId = User.GetUserId();
109
+ var assignedTo = request.AssignedToUserId;
110
+
111
+ if (!User.IsAdmin() && assignedTo.HasValue && assignedTo.Value != userId)
112
+ {
113
+ return Forbid();
114
+ }
115
+
116
+ var item = new TaskItem
117
+ {
118
+ Title = request.Title.Trim(),
119
+ Description = request.Description.Trim(),
120
+ Status = request.Status,
121
+ Priority = request.Priority,
122
+ Visibility = request.Visibility,
123
+ DueDate = request.DueDate,
124
+ CreatedByUserId = userId,
125
+ AssignedToUserId = assignedTo,
126
+ CreatedAt = DateTime.UtcNow,
127
+ UpdatedAt = DateTime.UtcNow
128
+ };
129
+
130
+ _db.TaskItems.Add(item);
131
+ await _db.SaveChangesAsync();
132
+ await _activity.AddAsync("Task created", "Task", item.Id.ToString(), userId);
133
+
134
+ var created = await BaseQuery().FirstAsync(x => x.Id == item.Id);
135
+ return CreatedAtAction(nameof(GetTask), new { id = item.Id }, ToResponse(created));
136
+ }
137
+
138
+ [HttpPut("{id:int}")]
139
+ public async Task<ActionResult<TaskResponse>> UpdateTask(int id, UpdateTaskRequest request)
140
+ {
141
+ var item = await BaseQuery(trackChanges: true).FirstOrDefaultAsync(x => x.Id == id);
142
+ if (item is null)
143
+ {
144
+ return NotFound(new { message = "Task was not found." });
145
+ }
146
+
147
+ var userId = User.GetUserId();
148
+ if (!CanWrite(item, userId, User.IsAdmin()))
149
+ {
150
+ return Forbid();
151
+ }
152
+
153
+ var validation = await ApplyTaskChangesAsync(item, request, userId, User.IsAdmin());
154
+ if (validation is not null)
155
+ {
156
+ return BadRequest(new { message = validation });
157
+ }
158
+
159
+ item.UpdatedAt = DateTime.UtcNow;
160
+ await _db.SaveChangesAsync();
161
+ await _activity.AddAsync("Task updated", "Task", item.Id.ToString(), userId);
162
+
163
+ var updated = await BaseQuery().FirstAsync(x => x.Id == item.Id);
164
+ return Ok(ToResponse(updated));
165
+ }
166
+
167
+ [HttpPatch("{id:int}/status")]
168
+ public async Task<ActionResult<TaskResponse>> UpdateStatus(int id, UpdateTaskStatusRequest request)
169
+ {
170
+ var item = await BaseQuery(trackChanges: true).FirstOrDefaultAsync(x => x.Id == id);
171
+ if (item is null)
172
+ {
173
+ return NotFound(new { message = "Task was not found." });
174
+ }
175
+
176
+ var userId = User.GetUserId();
177
+ var canChange = CanWrite(item, userId, User.IsAdmin()) || item.AssignedToUserId == userId;
178
+ if (!canChange)
179
+ {
180
+ return Forbid();
181
+ }
182
+
183
+ var oldStatus = item.Status;
184
+ item.Status = request.Status;
185
+ item.UpdatedAt = DateTime.UtcNow;
186
+
187
+ await _db.SaveChangesAsync();
188
+ await _activity.AddAsync($"Task status changed from {oldStatus} to {item.Status}", "Task", item.Id.ToString(), userId);
189
+
190
+ var updated = await BaseQuery().FirstAsync(x => x.Id == item.Id);
191
+ return Ok(ToResponse(updated));
192
+ }
193
+
194
+ [HttpDelete("{id:int}")]
195
+ public async Task<IActionResult> DeleteTask(int id)
196
+ {
197
+ var item = await _db.TaskItems.FindAsync(id);
198
+ if (item is null)
199
+ {
200
+ return NotFound(new { message = "Task was not found." });
201
+ }
202
+
203
+ var userId = User.GetUserId();
204
+ if (!CanWrite(item, userId, User.IsAdmin()))
205
+ {
206
+ return Forbid();
207
+ }
208
+
209
+ _db.TaskItems.Remove(item);
210
+ await _db.SaveChangesAsync();
211
+ await _activity.AddAsync("Task deleted", "Task", id.ToString(), userId);
212
+
213
+ return NoContent();
214
+ }
215
+
216
+ [HttpPost("{id:int}/edit-requests")]
217
+ public async Task<ActionResult<TaskEditRequestResponse>> CreateEditRequest(int id, CreateTaskEditRequest request)
218
+ {
219
+ var item = await BaseQuery(trackChanges: true).FirstOrDefaultAsync(x => x.Id == id);
220
+ if (item is null)
221
+ {
222
+ return NotFound(new { message = "Task was not found." });
223
+ }
224
+
225
+ var userId = User.GetUserId();
226
+ if (item.Visibility != TaskVisibility.Public)
227
+ {
228
+ return BadRequest(new { message = "Only public tasks can receive edit requests." });
229
+ }
230
+
231
+ if (CanWrite(item, userId, User.IsAdmin()))
232
+ {
233
+ return BadRequest(new { message = "Task owners and admins can edit the task directly." });
234
+ }
235
+
236
+ if (!CanRead(item, userId, User.IsAdmin()))
237
+ {
238
+ return Forbid();
239
+ }
240
+
241
+ var validation = await ValidateEditRequestAsync(request);
242
+ if (validation is not null)
243
+ {
244
+ return BadRequest(new { message = validation });
245
+ }
246
+
247
+ var editRequest = new TaskEditRequest
248
+ {
249
+ TaskItemId = item.Id,
250
+ RequestedByUserId = userId,
251
+ Title = request.Title?.Trim(),
252
+ Description = request.Description?.Trim(),
253
+ Status = request.Status,
254
+ Priority = request.Priority,
255
+ Visibility = request.Visibility,
256
+ DueDate = request.DueDate,
257
+ ClearDueDate = request.ClearDueDate,
258
+ AssignedToUserId = request.AssignedToUserId,
259
+ ClearAssignedUser = request.ClearAssignedUser,
260
+ StatusOfRequest = EditRequestStatus.Pending,
261
+ CreatedAt = DateTime.UtcNow
262
+ };
263
+
264
+ _db.TaskEditRequests.Add(editRequest);
265
+ await _db.SaveChangesAsync();
266
+ await _activity.AddAsync("Task edit requested", "Task", item.Id.ToString(), userId);
267
+
268
+ var created = await EditRequestBaseQuery().FirstAsync(x => x.Id == editRequest.Id);
269
+ return Ok(ToEditRequestResponse(created));
270
+ }
271
+
272
+ [HttpPost("edit-requests/{requestId:guid}/accept")]
273
+ public async Task<ActionResult<TaskEditRequestResponse>> AcceptEditRequest(Guid requestId, ResolveTaskEditRequest request)
274
+ {
275
+ var editRequest = await EditRequestBaseQuery(trackChanges: true)
276
+ .FirstOrDefaultAsync(x => x.Id == requestId);
277
+ if (editRequest?.TaskItem is null)
278
+ {
279
+ return NotFound(new { message = "Edit request was not found." });
280
+ }
281
+
282
+ var userId = User.GetUserId();
283
+ if (!CanWrite(editRequest.TaskItem, userId, User.IsAdmin()))
284
+ {
285
+ return Forbid();
286
+ }
287
+
288
+ if (editRequest.StatusOfRequest != EditRequestStatus.Pending)
289
+ {
290
+ return BadRequest(new { message = "Only pending edit requests can be accepted." });
291
+ }
292
+
293
+ var validation = await ApplyEditRequestToTaskAsync(editRequest.TaskItem, editRequest);
294
+ if (validation is not null)
295
+ {
296
+ return BadRequest(new { message = validation });
297
+ }
298
+
299
+ editRequest.StatusOfRequest = EditRequestStatus.Accepted;
300
+ editRequest.OwnerNote = request.OwnerNote?.Trim();
301
+ editRequest.ResolvedAt = DateTime.UtcNow;
302
+ editRequest.TaskItem.UpdatedAt = DateTime.UtcNow;
303
+
304
+ var otherPendingRequests = await _db.TaskEditRequests
305
+ .Where(x => x.TaskItemId == editRequest.TaskItemId &&
306
+ x.Id != editRequest.Id &&
307
+ x.StatusOfRequest == EditRequestStatus.Pending)
308
+ .ToListAsync();
309
+
310
+ foreach (var other in otherPendingRequests)
311
+ {
312
+ other.StatusOfRequest = EditRequestStatus.Rejected;
313
+ other.OwnerNote = "Rejected automatically because another edit request was accepted.";
314
+ other.ResolvedAt = DateTime.UtcNow;
315
+ }
316
+
317
+ await _db.SaveChangesAsync();
318
+ await _activity.AddAsync("Task edit request accepted", "Task", editRequest.TaskItemId.ToString(), userId);
319
+
320
+ var updated = await EditRequestBaseQuery().FirstAsync(x => x.Id == editRequest.Id);
321
+ return Ok(ToEditRequestResponse(updated));
322
+ }
323
+
324
+ [HttpPost("edit-requests/{requestId:guid}/reject")]
325
+ public async Task<ActionResult<TaskEditRequestResponse>> RejectEditRequest(Guid requestId, ResolveTaskEditRequest request)
326
+ {
327
+ var editRequest = await EditRequestBaseQuery(trackChanges: true)
328
+ .FirstOrDefaultAsync(x => x.Id == requestId);
329
+ if (editRequest?.TaskItem is null)
330
+ {
331
+ return NotFound(new { message = "Edit request was not found." });
332
+ }
333
+
334
+ var userId = User.GetUserId();
335
+ if (!CanWrite(editRequest.TaskItem, userId, User.IsAdmin()))
336
+ {
337
+ return Forbid();
338
+ }
339
+
340
+ if (editRequest.StatusOfRequest != EditRequestStatus.Pending)
341
+ {
342
+ return BadRequest(new { message = "Only pending edit requests can be rejected." });
343
+ }
344
+
345
+ editRequest.StatusOfRequest = EditRequestStatus.Rejected;
346
+ editRequest.OwnerNote = request.OwnerNote?.Trim();
347
+ editRequest.ResolvedAt = DateTime.UtcNow;
348
+
349
+ await _db.SaveChangesAsync();
350
+ await _activity.AddAsync("Task edit request rejected", "Task", editRequest.TaskItemId.ToString(), userId);
351
+
352
+ var updated = await EditRequestBaseQuery().FirstAsync(x => x.Id == editRequest.Id);
353
+ return Ok(ToEditRequestResponse(updated));
354
+ }
355
+
356
+ private IQueryable<TaskItem> BaseQuery(bool trackChanges = false)
357
+ {
358
+ IQueryable<TaskItem> query = _db.TaskItems
359
+ .Include(x => x.CreatedByUser)
360
+ .Include(x => x.AssignedToUser)
361
+ .Include(x => x.EditRequests);
362
+
363
+ if (!trackChanges)
364
+ {
365
+ query = query.AsNoTracking();
366
+ }
367
+
368
+ return query.OrderBy(x => x.Id);
369
+ }
370
+
371
+ private IQueryable<TaskEditRequest> EditRequestBaseQuery(bool trackChanges = false)
372
+ {
373
+ IQueryable<TaskEditRequest> query = _db.TaskEditRequests
374
+ .Include(x => x.TaskItem)
375
+ .ThenInclude(x => x!.CreatedByUser)
376
+ .Include(x => x.TaskItem)
377
+ .ThenInclude(x => x!.AssignedToUser)
378
+ .Include(x => x.RequestedByUser)
379
+ .Include(x => x.AssignedToUser);
380
+
381
+ if (!trackChanges)
382
+ {
383
+ query = query.AsNoTracking();
384
+ }
385
+
386
+ return query;
387
+ }
388
+
389
+ private static IQueryable<TaskItem> ApplyFilters(IQueryable<TaskItem> query, TaskQuery filters)
390
+ {
391
+ if (!string.IsNullOrWhiteSpace(filters.Q))
392
+ {
393
+ var q = filters.Q.Trim().ToLowerInvariant();
394
+ query = query.Where(x =>
395
+ x.Title.ToLower().Contains(q) ||
396
+ x.Description.ToLower().Contains(q));
397
+ }
398
+
399
+ if (filters.Status.HasValue)
400
+ {
401
+ query = query.Where(x => x.Status == filters.Status.Value);
402
+ }
403
+
404
+ if (filters.Priority.HasValue)
405
+ {
406
+ query = query.Where(x => x.Priority == filters.Priority.Value);
407
+ }
408
+
409
+ if (filters.Visibility.HasValue)
410
+ {
411
+ query = query.Where(x => x.Visibility == filters.Visibility.Value);
412
+ }
413
+
414
+ if (filters.AssignedToUserId.HasValue)
415
+ {
416
+ query = query.Where(x => x.AssignedToUserId == filters.AssignedToUserId.Value);
417
+ }
418
+
419
+ if (filters.DueFrom.HasValue)
420
+ {
421
+ query = query.Where(x => x.DueDate >= filters.DueFrom.Value);
422
+ }
423
+
424
+ if (filters.DueTo.HasValue)
425
+ {
426
+ query = query.Where(x => x.DueDate <= filters.DueTo.Value);
427
+ }
428
+
429
+ return query;
430
+ }
431
+
432
+ private static async Task<IReadOnlyList<TaskResponse>> ToListAsync(IQueryable<TaskItem> query)
433
+ {
434
+ var items = await query.ToListAsync();
435
+ return items.Select(ToResponse).ToList();
436
+ }
437
+
438
+ private static TaskResponse ToResponse(TaskItem item)
439
+ {
440
+ return new TaskResponse
441
+ {
442
+ Id = item.Id,
443
+ Title = item.Title,
444
+ Description = item.Description,
445
+ Status = item.Status,
446
+ Priority = item.Priority,
447
+ Visibility = item.Visibility,
448
+ DueDate = item.DueDate,
449
+ CreatedByUserId = item.CreatedByUserId,
450
+ CreatedByFullName = item.CreatedByUser == null ? "Unknown" : item.CreatedByUser.FullName,
451
+ AssignedToUserId = item.AssignedToUserId,
452
+ AssignedToFullName = item.AssignedToUser?.FullName,
453
+ CreatedAt = item.CreatedAt,
454
+ UpdatedAt = item.UpdatedAt,
455
+ IsOverdue = item.DueDate.HasValue && item.DueDate.Value < DateTime.UtcNow && item.Status != TaskState.Done,
456
+ PendingEditRequestCount = item.EditRequests.Count(x => x.StatusOfRequest == EditRequestStatus.Pending)
457
+ };
458
+ }
459
+
460
+ private static TaskEditRequestResponse ToEditRequestResponse(TaskEditRequest request)
461
+ {
462
+ return new TaskEditRequestResponse
463
+ {
464
+ Id = request.Id,
465
+ TaskId = request.TaskItemId,
466
+ TaskTitle = request.TaskItem?.Title ?? "Unknown task",
467
+ RequestedByUserId = request.RequestedByUserId,
468
+ RequestedByFullName = request.RequestedByUser?.FullName ?? "Unknown",
469
+ RequestedByEmail = request.RequestedByUser?.Email ?? string.Empty,
470
+ Title = request.Title,
471
+ Description = request.Description,
472
+ Status = request.Status,
473
+ Priority = request.Priority,
474
+ Visibility = request.Visibility,
475
+ DueDate = request.DueDate,
476
+ ClearDueDate = request.ClearDueDate,
477
+ AssignedToUserId = request.AssignedToUserId,
478
+ AssignedToFullName = request.AssignedToUser?.FullName,
479
+ ClearAssignedUser = request.ClearAssignedUser,
480
+ StatusOfRequest = request.StatusOfRequest,
481
+ OwnerNote = request.OwnerNote,
482
+ CreatedAt = request.CreatedAt,
483
+ ResolvedAt = request.ResolvedAt
484
+ };
485
+ }
486
+
487
+ private static bool CanRead(TaskItem item, Guid userId, bool isAdmin)
488
+ {
489
+ return isAdmin ||
490
+ item.Visibility == TaskVisibility.Public ||
491
+ item.CreatedByUserId == userId ||
492
+ item.AssignedToUserId == userId;
493
+ }
494
+
495
+ private static bool CanWrite(TaskItem item, Guid userId, bool isAdmin)
496
+ {
497
+ return isAdmin || item.CreatedByUserId == userId;
498
+ }
499
+
500
+ private async Task<string?> ApplyTaskChangesAsync(
501
+ TaskItem item,
502
+ UpdateTaskRequest request,
503
+ Guid userId,
504
+ bool isAdmin)
505
+ {
506
+ if (!string.IsNullOrWhiteSpace(request.Title))
507
+ {
508
+ item.Title = request.Title.Trim();
509
+ }
510
+
511
+ if (request.Description is not null)
512
+ {
513
+ item.Description = request.Description.Trim();
514
+ }
515
+
516
+ if (request.Status.HasValue)
517
+ {
518
+ item.Status = request.Status.Value;
519
+ }
520
+
521
+ if (request.Priority.HasValue)
522
+ {
523
+ item.Priority = request.Priority.Value;
524
+ }
525
+
526
+ if (request.Visibility.HasValue)
527
+ {
528
+ item.Visibility = request.Visibility.Value;
529
+ }
530
+
531
+ if (request.ClearDueDate)
532
+ {
533
+ item.DueDate = null;
534
+ }
535
+ else if (request.DueDate.HasValue)
536
+ {
537
+ item.DueDate = request.DueDate;
538
+ }
539
+
540
+ if (request.ClearAssignedUser)
541
+ {
542
+ item.AssignedToUserId = null;
543
+ }
544
+ else if (request.AssignedToUserId.HasValue)
545
+ {
546
+ var validation = await ValidateAssignedUserAsync(request.AssignedToUserId.Value);
547
+ if (validation is not null)
548
+ {
549
+ return validation;
550
+ }
551
+
552
+ if (!isAdmin && request.AssignedToUserId.Value != userId)
553
+ {
554
+ return "Normal users can only assign tasks to themselves.";
555
+ }
556
+
557
+ item.AssignedToUserId = request.AssignedToUserId;
558
+ }
559
+
560
+ return null;
561
+ }
562
+
563
+ private async Task<string?> ApplyEditRequestToTaskAsync(TaskItem item, TaskEditRequest request)
564
+ {
565
+ if (!string.IsNullOrWhiteSpace(request.Title))
566
+ {
567
+ item.Title = request.Title.Trim();
568
+ }
569
+
570
+ if (request.Description is not null)
571
+ {
572
+ item.Description = request.Description.Trim();
573
+ }
574
+
575
+ if (request.Status.HasValue)
576
+ {
577
+ item.Status = request.Status.Value;
578
+ }
579
+
580
+ if (request.Priority.HasValue)
581
+ {
582
+ item.Priority = request.Priority.Value;
583
+ }
584
+
585
+ if (request.Visibility.HasValue)
586
+ {
587
+ item.Visibility = request.Visibility.Value;
588
+ }
589
+
590
+ if (request.ClearDueDate)
591
+ {
592
+ item.DueDate = null;
593
+ }
594
+ else if (request.DueDate.HasValue)
595
+ {
596
+ item.DueDate = request.DueDate;
597
+ }
598
+
599
+ if (request.ClearAssignedUser)
600
+ {
601
+ item.AssignedToUserId = null;
602
+ }
603
+ else if (request.AssignedToUserId.HasValue)
604
+ {
605
+ var validation = await ValidateAssignedUserAsync(request.AssignedToUserId.Value);
606
+ if (validation is not null)
607
+ {
608
+ return validation;
609
+ }
610
+
611
+ item.AssignedToUserId = request.AssignedToUserId;
612
+ }
613
+
614
+ return null;
615
+ }
616
+
617
+ private async Task<string?> ValidateTaskInputAsync(string title, Guid? assignedToUserId)
618
+ {
619
+ if (string.IsNullOrWhiteSpace(title))
620
+ {
621
+ return "Task title is required.";
622
+ }
623
+
624
+ if (assignedToUserId.HasValue)
625
+ {
626
+ return await ValidateAssignedUserAsync(assignedToUserId.Value);
627
+ }
628
+
629
+ return null;
630
+ }
631
+
632
+ private async Task<string?> ValidateEditRequestAsync(CreateTaskEditRequest request)
633
+ {
634
+ if (request.Title is not null && string.IsNullOrWhiteSpace(request.Title))
635
+ {
636
+ return "Title cannot be empty.";
637
+ }
638
+
639
+ if (request.AssignedToUserId.HasValue)
640
+ {
641
+ return await ValidateAssignedUserAsync(request.AssignedToUserId.Value);
642
+ }
643
+
644
+ return null;
645
+ }
646
+
647
+ private async Task<string?> ValidateAssignedUserAsync(Guid assignedToUserId)
648
+ {
649
+ var exists = await _db.Users.AnyAsync(x => x.Id == assignedToUserId && x.IsActive);
650
+ return exists ? null : "Assigned user was not found or is inactive.";
651
+ }
652
+ }