diff --git a/dotnet/AipsCore/Application/Models/Whiteboard/Command/DeleteWhiteboard/DeleteWhiteboardCommand.cs b/dotnet/AipsCore/Application/Models/Whiteboard/Command/DeleteWhiteboard/DeleteWhiteboardCommand.cs new file mode 100644 index 0000000..af6ccab --- /dev/null +++ b/dotnet/AipsCore/Application/Models/Whiteboard/Command/DeleteWhiteboard/DeleteWhiteboardCommand.cs @@ -0,0 +1,5 @@ +using AipsCore.Application.Abstract.Command; + +namespace AipsCore.Application.Models.Whiteboard.Command.DeleteWhiteboard; + +public record DeleteWhiteboardCommand(string WhiteboardId) : ICommand; \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/Whiteboard/Command/DeleteWhiteboard/DeleteWhiteboardCommandHandler.cs b/dotnet/AipsCore/Application/Models/Whiteboard/Command/DeleteWhiteboard/DeleteWhiteboardCommandHandler.cs new file mode 100644 index 0000000..d51a618 --- /dev/null +++ b/dotnet/AipsCore/Application/Models/Whiteboard/Command/DeleteWhiteboard/DeleteWhiteboardCommandHandler.cs @@ -0,0 +1,48 @@ +using AipsCore.Application.Abstract.Command; +using AipsCore.Application.Abstract.UserContext; +using AipsCore.Domain.Abstract; +using AipsCore.Domain.Abstract.Validation; +using AipsCore.Domain.Common.Validation; +using AipsCore.Domain.Models.Whiteboard.External; +using AipsCore.Domain.Models.Whiteboard.Validation; +using AipsCore.Domain.Models.Whiteboard.ValueObjects; + +namespace AipsCore.Application.Models.Whiteboard.Command.DeleteWhiteboard; + +public class DeleteWhiteboardCommandHandler : ICommandHandler +{ + private readonly IUserContext _userContext; + private readonly IWhiteboardRepository _whiteboardRepository; + private readonly IUnitOfWork _unitOfWork; + + public DeleteWhiteboardCommandHandler( + IUserContext userContext, + IWhiteboardRepository whiteboardRepository, + IUnitOfWork unitOfWork) + { + _userContext = userContext; + _whiteboardRepository = whiteboardRepository; + _unitOfWork = unitOfWork; + } + + public async Task Handle(DeleteWhiteboardCommand command, CancellationToken cancellationToken = default) + { + var whiteboardId = new WhiteboardId(command.WhiteboardId); + var userId = _userContext.GetCurrentUserId(); + + var whiteboard = await _whiteboardRepository.GetByIdAsync(whiteboardId, cancellationToken); + + if (whiteboard is null) + { + throw new ValidationException(WhiteboardErrors.NotFound(whiteboardId)); + } + + if (!whiteboard.CanUserDelete(userId)) + { + throw new ValidationException(WhiteboardErrors.OnlyOwnerCanDeleteWhiteboard(userId)); + } + + await _whiteboardRepository.SoftDeleteAsync(whiteboardId, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetRecentWhiteboards/GetRecentWhiteboardsQueryHandler.cs b/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetRecentWhiteboards/GetRecentWhiteboardsQueryHandler.cs index 0c64e85..23ddb96 100644 --- a/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetRecentWhiteboards/GetRecentWhiteboardsQueryHandler.cs +++ b/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetRecentWhiteboards/GetRecentWhiteboardsQueryHandler.cs @@ -1,5 +1,6 @@ using AipsCore.Application.Abstract.Query; using AipsCore.Application.Abstract.UserContext; +using AipsCore.Domain.Models.Whiteboard.Enums; using AipsCore.Infrastructure.Persistence.Db; using Microsoft.EntityFrameworkCore; @@ -32,7 +33,8 @@ public class GetRecentWhiteboardsQueryHandler : IQueryHandler ( m.UserId == userIdGuid && m.IsBanned == false && - m.Whiteboard != null + m.Whiteboard != null && + m.Whiteboard.State != WhiteboardState.Deleted )) .OrderByDescending(m => m.LastInteractedAt) .Select(m => m.Whiteboard!); diff --git a/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetWhiteboardHistory/GetWhiteboardHistoryQueryHandler.cs b/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetWhiteboardHistory/GetWhiteboardHistoryQueryHandler.cs index a93e57a..c64e4f4 100644 --- a/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetWhiteboardHistory/GetWhiteboardHistoryQueryHandler.cs +++ b/dotnet/AipsCore/Application/Models/Whiteboard/Query/GetWhiteboardHistory/GetWhiteboardHistoryQueryHandler.cs @@ -1,5 +1,6 @@ using AipsCore.Application.Abstract.Query; using AipsCore.Application.Abstract.UserContext; +using AipsCore.Domain.Models.Whiteboard.Enums; using AipsCore.Infrastructure.Persistence.Db; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,7 @@ public class GetWhiteboardHistoryQueryHandler var userIdGuid = new Guid(_userContext.GetCurrentUserId().IdValue); return await _context.Whiteboards - .Where(w => w.OwnerId == userIdGuid) + .Where(w => w.OwnerId == userIdGuid && w.State != WhiteboardState.Deleted) .ToListAsync(cancellationToken); } } \ No newline at end of file diff --git a/dotnet/AipsCore/Domain/Abstract/ISoftDeletableRepository.cs b/dotnet/AipsCore/Domain/Abstract/ISoftDeletableRepository.cs new file mode 100644 index 0000000..e0d574e --- /dev/null +++ b/dotnet/AipsCore/Domain/Abstract/ISoftDeletableRepository.cs @@ -0,0 +1,8 @@ +using AipsCore.Domain.Common.ValueObjects; + +namespace AipsCore.Domain.Abstract; + +public interface ISoftDeletableRepository where TId : DomainId +{ + Task SoftDeleteAsync(TId id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/dotnet/AipsCore/Domain/Models/Whiteboard/External/IWhiteboardRepository.cs b/dotnet/AipsCore/Domain/Models/Whiteboard/External/IWhiteboardRepository.cs index 0fcfe36..67812ec 100644 --- a/dotnet/AipsCore/Domain/Models/Whiteboard/External/IWhiteboardRepository.cs +++ b/dotnet/AipsCore/Domain/Models/Whiteboard/External/IWhiteboardRepository.cs @@ -3,7 +3,8 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects; namespace AipsCore.Domain.Models.Whiteboard.External; -public interface IWhiteboardRepository : IAbstractRepository +public interface IWhiteboardRepository + : IAbstractRepository, ISoftDeletableRepository { Task WhiteboardCodeExists(WhiteboardCode whiteboardCode); } \ No newline at end of file diff --git a/dotnet/AipsCore/Domain/Models/Whiteboard/Validation/WhiteboardErrors.cs b/dotnet/AipsCore/Domain/Models/Whiteboard/Validation/WhiteboardErrors.cs index 262e72b..ad1070e 100644 --- a/dotnet/AipsCore/Domain/Models/Whiteboard/Validation/WhiteboardErrors.cs +++ b/dotnet/AipsCore/Domain/Models/Whiteboard/Validation/WhiteboardErrors.cs @@ -38,4 +38,12 @@ public class WhiteboardErrors : AbstractErrors return CreateValidationError(code, message); } + + public static ValidationError OnlyOwnerCanDeleteWhiteboard(UserId currentUserId) + { + string code = "only_owner_can_delete_whiteboard"; + string message = $"Only owner of whiteboard can delete the whiteboard. Current user id: '{currentUserId}' is not the owner."; + + return CreateValidationError(code, message); + } } \ No newline at end of file diff --git a/dotnet/AipsCore/Domain/Models/Whiteboard/Whiteboard.CanUserDelete.cs b/dotnet/AipsCore/Domain/Models/Whiteboard/Whiteboard.CanUserDelete.cs new file mode 100644 index 0000000..b11e8de --- /dev/null +++ b/dotnet/AipsCore/Domain/Models/Whiteboard/Whiteboard.CanUserDelete.cs @@ -0,0 +1,11 @@ +using AipsCore.Domain.Models.User.ValueObjects; + +namespace AipsCore.Domain.Models.Whiteboard; + +public partial class Whiteboard +{ + public bool CanUserDelete(UserId userId) + { + return WhiteboardOwnerId.IdValue == userId.IdValue; + } +} \ No newline at end of file diff --git a/dotnet/AipsCore/Infrastructure/Persistence/Whiteboard/WhiteboardRepository.cs b/dotnet/AipsCore/Infrastructure/Persistence/Whiteboard/WhiteboardRepository.cs index d606cee..29518f9 100644 --- a/dotnet/AipsCore/Infrastructure/Persistence/Whiteboard/WhiteboardRepository.cs +++ b/dotnet/AipsCore/Infrastructure/Persistence/Whiteboard/WhiteboardRepository.cs @@ -1,3 +1,4 @@ +using AipsCore.Domain.Models.Whiteboard.Enums; using AipsCore.Domain.Models.Whiteboard.External; using AipsCore.Domain.Models.Whiteboard.ValueObjects; using AipsCore.Infrastructure.Persistence.Abstract; @@ -61,4 +62,16 @@ public class WhiteboardRepository { return await Context.Whiteboards.AnyAsync(w => w.Code == whiteboardCode.CodeValue); } + + public async Task SoftDeleteAsync(WhiteboardId id, CancellationToken cancellationToken = default) + { + var entity = await Context.Whiteboards.FindAsync([new Guid(id.IdValue)], cancellationToken); + + if (entity != null) + { + entity.State = WhiteboardState.Deleted; + entity.DeletedAt = DateTime.UtcNow; + Context.Whiteboards.Update(entity); + } + } } \ No newline at end of file diff --git a/dotnet/AipsWebApi/Controllers/WhiteboardController.cs b/dotnet/AipsWebApi/Controllers/WhiteboardController.cs index 0bbc8d3..fd418ae 100644 --- a/dotnet/AipsWebApi/Controllers/WhiteboardController.cs +++ b/dotnet/AipsWebApi/Controllers/WhiteboardController.cs @@ -1,5 +1,6 @@ using AipsCore.Application.Abstract; using AipsCore.Application.Models.Whiteboard.Command.CreateWhiteboard; +using AipsCore.Application.Models.Whiteboard.Command.DeleteWhiteboard; using AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards; using AipsCore.Application.Models.Whiteboard.Query.GetWhiteboard; using AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardHistory; @@ -41,6 +42,15 @@ public class WhiteboardController : ControllerBase return Ok(whiteboard); } + [Authorize] + [HttpDelete("{whiteboardId}")] + public async Task DeleteWhiteboard([FromRoute] string whiteboardId, CancellationToken cancellationToken) + { + await _dispatcher.Execute(new DeleteWhiteboardCommand(whiteboardId), cancellationToken); + return Ok(); + } + + [Authorize] [HttpGet("history")] public async Task>> GetWhiteboardHistory(CancellationToken cancellationToken) diff --git a/front/bun.lock b/front/bun.lock index 9f56676..a487960 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -8,8 +8,8 @@ "@microsoft/signalr": "^10.0.0", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1", "pinia": "^3.0.4", - "signalr": "^2.4.3", "vue": "^3.5.27", "vue-router": "^5.0.1", }, @@ -398,6 +398,8 @@ "bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="], + "bootstrap-icons": ["bootstrap-icons@1.13.1", "", {}, "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -548,8 +550,6 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jquery": ["jquery@4.0.0", "", {}, "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -742,8 +742,6 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "signalr": ["signalr@2.4.3", "", { "dependencies": { "jquery": ">=1.6.4" } }, "sha512-RbBKFVCZvDgyyxZDeu6Yck9T+diZO07GB0bDiKondUhBY1H8JRQSOq8R0pLkf47ddllQAssYlp7ckQAeom24mw=="], - "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/front/package.json b/front/package.json index 28d7b14..bdb898f 100644 --- a/front/package.json +++ b/front/package.json @@ -17,6 +17,7 @@ "@microsoft/signalr": "^10.0.0", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1", "pinia": "^3.0.4", "vue": "^3.5.27", "vue-router": "^5.0.1" diff --git a/front/src/assets/scss/main.scss b/front/src/assets/scss/main.scss index aa127f5..b220e20 100644 --- a/front/src/assets/scss/main.scss +++ b/front/src/assets/scss/main.scss @@ -1,3 +1,4 @@ @import 'variables'; @import 'bootstrap/scss/bootstrap'; +@import 'bootstrap-icons/font/bootstrap-icons.css'; @import 'app'; diff --git a/front/src/components/WhiteboardHistoryItem.vue b/front/src/components/WhiteboardHistoryItem.vue index 07b53ef..978a788 100644 --- a/front/src/components/WhiteboardHistoryItem.vue +++ b/front/src/components/WhiteboardHistoryItem.vue @@ -1,8 +1,15 @@ diff --git a/front/src/services/whiteboardService.ts b/front/src/services/whiteboardService.ts index 4b78ba9..db0711e 100644 --- a/front/src/services/whiteboardService.ts +++ b/front/src/services/whiteboardService.ts @@ -18,6 +18,10 @@ export const whiteboardService = { async getWhiteboardById(id: string): Promise { return await api.get(`/api/Whiteboard/${id}`).then(mapWhiteboard) + }, + + async deleteWhiteboard(id: string): Promise { + await api.delete(`/api/Whiteboard/${id}`) } } @@ -25,6 +29,7 @@ function mapWhiteboard(raw: any): Whiteboard { return { id: raw.id, ownerId: raw.ownerId, + code: raw.code, title: raw.title, createdAt: new Date(raw.createdAt), deletedAt: raw.deletedAt ? new Date(raw.deletedAt) : undefined, diff --git a/front/src/stores/whiteboards.ts b/front/src/stores/whiteboards.ts index cfc9008..a67650a 100644 --- a/front/src/stores/whiteboards.ts +++ b/front/src/stores/whiteboards.ts @@ -6,6 +6,7 @@ import { whiteboardService } from '@/services/whiteboardService' export const useWhiteboardsStore = defineStore('whiteboards', () => { const ownedWhiteboards = ref([]) const recentWhiteboards = ref([]) + const currentWhiteboard = ref(null) const isLoading = ref(false) const error = ref(null) @@ -52,6 +53,36 @@ export const useWhiteboardsStore = defineStore('whiteboards', () => { return newWhiteboard.id; } + async function deleteWhiteboard(id: string): Promise { + isLoading.value = true + error.value = null + + try { + await whiteboardService.deleteWhiteboard(id) + ownedWhiteboards.value = ownedWhiteboards.value.filter(wb => wb.id !== id) + } catch (err: any) { + error.value = err.message ?? 'Failed to delete whiteboard' + throw err + } finally { + isLoading.value = false + } + } + + function getCurrentWhiteboard(): Whiteboard | null { + return currentWhiteboard.value + } + + function selectWhiteboard(whiteboardId: string) { + currentWhiteboard.value = + ownedWhiteboards.value.find(wb => wb.id === whiteboardId) || + recentWhiteboards.value.find(wb => wb.id === whiteboardId) || + null + } + + function deselectWhiteboard() { + currentWhiteboard.value = null + } + function clearWhiteboards() { ownedWhiteboards.value = [] recentWhiteboards.value = [] @@ -65,6 +96,10 @@ export const useWhiteboardsStore = defineStore('whiteboards', () => { getWhiteboardHistory: getWhiteboardHistory, getRecentWhiteboards: getRecentWhiteboards, createNewWhiteboard: createNewWhiteboard, - clearWhiteboards: clearWhiteboards + deleteWhiteboard: deleteWhiteboard, + getCurrentWhiteboard: getCurrentWhiteboard, + selectWhiteboard: selectWhiteboard, + deselectWhiteboard: deselectWhiteboard, + clearWhiteboards: clearWhiteboards, } }) diff --git a/front/src/types/index.ts b/front/src/types/index.ts index 649359f..f26dc5e 100644 --- a/front/src/types/index.ts +++ b/front/src/types/index.ts @@ -25,6 +25,7 @@ export interface AuthResponse { export interface Whiteboard { id: string ownerId: string + code: string title: string createdAt: Date deletedAt?: Date diff --git a/front/src/views/WhiteboardView.vue b/front/src/views/WhiteboardView.vue index d4a36cb..d556ed5 100644 --- a/front/src/views/WhiteboardView.vue +++ b/front/src/views/WhiteboardView.vue @@ -1,32 +1,42 @@