diff --git a/ROADMAP.md b/ROADMAP.md index 95409ab7..924c271d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,12 +56,12 @@ The project implements a **Hexagonal Architecture** backend (Java 24, Javalin, A - [x] Add "Add to Collection" action in `LinkList` items. - **Links Domain**: - **Backend**: - - [ ] Implement `UpdateLinkUseCase` (Edit title, description, tags). - - [ ] Implement `DeleteLinkUseCase`. + - [x] Implement `UpdateLinkUseCase` (Edit title, description, tags). + - [x] Implement `DeleteLinkUseCase`. - **Frontend**: - - [ ] Add Edit Link Modal/Form. - - [ ] Add Delete Link confirmation dialog. - - [ ] Update `LinkList` to reflect changes immediately. + - [x] Add Edit Link Modal/Form. + - [x] Add Delete Link confirmation dialog. + - [x] Update `LinkList` to reflect changes immediately. - **Content Domain**: - **Backend**: - [ ] Review `DownloadContentService` robustness (retries). diff --git a/src/main/java/it/robfrank/linklift/Application.java b/src/main/java/it/robfrank/linklift/Application.java index e100e0ca..94f5d82a 100644 --- a/src/main/java/it/robfrank/linklift/Application.java +++ b/src/main/java/it/robfrank/linklift/Application.java @@ -320,6 +320,11 @@ public Javalin start(int port) { GetContentController getContentController = new GetContentController(getContentUseCase); AuthenticationController authenticationController = new AuthenticationController(userService, authenticationService, authenticationService); + // Initialize Link Management + UpdateLinkUseCase updateLinkUseCase = new UpdateLinkService(linkPersistenceAdapter, linkPersistenceAdapter); + DeleteLinkUseCase deleteLinkUseCase = new DeleteLinkService(linkPersistenceAdapter, linkPersistenceAdapter); + LinkController linkController = new LinkController(updateLinkUseCase, deleteLinkUseCase); + // Build and start web application Javalin app = new WebBuilder() .withAuthorizationService(authorizationService) @@ -329,6 +334,7 @@ public Javalin start(int port) { .withGetContentController(getContentController) .withCollectionController(collectionController) .withGetRelatedLinksController(getRelatedLinksController) + .withLinkManagementController(linkController) .build(); app.start(port); diff --git a/src/main/java/it/robfrank/linklift/adapter/in/web/LinkController.java b/src/main/java/it/robfrank/linklift/adapter/in/web/LinkController.java new file mode 100644 index 00000000..d84f48e9 --- /dev/null +++ b/src/main/java/it/robfrank/linklift/adapter/in/web/LinkController.java @@ -0,0 +1,46 @@ +package it.robfrank.linklift.adapter.in.web; + +import io.javalin.http.Context; +import it.robfrank.linklift.adapter.in.web.security.SecurityContext; +import it.robfrank.linklift.application.domain.model.Link; +import it.robfrank.linklift.application.port.in.DeleteLinkUseCase; +import it.robfrank.linklift.application.port.in.UpdateLinkCommand; +import it.robfrank.linklift.application.port.in.UpdateLinkUseCase; +import java.util.Objects; + +public class LinkController { + + private final UpdateLinkUseCase updateLinkUseCase; + private final DeleteLinkUseCase deleteLinkUseCase; + + public LinkController(UpdateLinkUseCase updateLinkUseCase, DeleteLinkUseCase deleteLinkUseCase) { + this.updateLinkUseCase = updateLinkUseCase; + this.deleteLinkUseCase = deleteLinkUseCase; + } + + public void updateLink(Context ctx) { + String id = Objects.requireNonNull(ctx.pathParam("id")); + String currentUserId = Objects.requireNonNull(SecurityContext.getCurrentUserId(ctx)); + + UpdateLinkRequest request = ctx.bodyAsClass(UpdateLinkRequest.class); + + UpdateLinkCommand command = new UpdateLinkCommand(id, request.title(), request.description(), currentUserId); + + Link updatedLink = updateLinkUseCase.updateLink(command); + + ctx.json(new UpdateLinkResponse(updatedLink)); + } + + public void deleteLink(Context ctx) { + String id = Objects.requireNonNull(ctx.pathParam("id")); + String currentUserId = Objects.requireNonNull(SecurityContext.getCurrentUserId(ctx)); + + deleteLinkUseCase.deleteLink(id, currentUserId); + + ctx.status(204); + } + + public record UpdateLinkRequest(String title, String description) {} + + public record UpdateLinkResponse(Link data) {} +} diff --git a/src/main/java/it/robfrank/linklift/adapter/out/persistence/ArcadeLinkRepository.java b/src/main/java/it/robfrank/linklift/adapter/out/persistence/ArcadeLinkRepository.java index 216581d8..606ef0e0 100644 --- a/src/main/java/it/robfrank/linklift/adapter/out/persistence/ArcadeLinkRepository.java +++ b/src/main/java/it/robfrank/linklift/adapter/out/persistence/ArcadeLinkRepository.java @@ -61,6 +61,28 @@ public Link saveLink(Link link) { } } + public Link updateLink(Link link) { + try { + database.transaction(() -> { + database.command( + "sql", + """ + UPDATE Link SET + title = ?, + description = ? + WHERE id = ? + """, + link.title(), + link.description(), + link.id() + ); + }); + return link; + } catch (ArcadeDBException e) { + throw new DatabaseException("Failed to update link: " + link.id(), e); + } + } + /** * Save a link and create an OwnsLink relationship to the specified user. * This method properly uses ArcadeDB's graph capabilities. diff --git a/src/main/java/it/robfrank/linklift/adapter/out/persistence/LinkPersistenceAdapter.java b/src/main/java/it/robfrank/linklift/adapter/out/persistence/LinkPersistenceAdapter.java index 2fa06f3a..968e3cf0 100644 --- a/src/main/java/it/robfrank/linklift/adapter/out/persistence/LinkPersistenceAdapter.java +++ b/src/main/java/it/robfrank/linklift/adapter/out/persistence/LinkPersistenceAdapter.java @@ -4,12 +4,15 @@ import it.robfrank.linklift.application.domain.model.Link; import it.robfrank.linklift.application.domain.model.LinkPage; import it.robfrank.linklift.application.port.in.ListLinksQuery; +import it.robfrank.linklift.application.port.out.DeleteLinkPort; import it.robfrank.linklift.application.port.out.LoadLinksPort; import it.robfrank.linklift.application.port.out.SaveLinkPort; +import it.robfrank.linklift.application.port.out.UpdateLinkPort; import java.util.List; import java.util.Optional; +import org.jspecify.annotations.NonNull; -public class LinkPersistenceAdapter implements SaveLinkPort, LoadLinksPort { +public class LinkPersistenceAdapter implements SaveLinkPort, LoadLinksPort, UpdateLinkPort, DeleteLinkPort { private final ArcadeLinkRepository linkRepository; @@ -75,7 +78,8 @@ public Optional getLinkOwner(String linkId) { /** * Delete a link and its relationships. */ - public void deleteLink(String linkId) { + @Override + public void deleteLink(@NonNull String linkId) { linkRepository.deleteLink(linkId); } @@ -90,4 +94,9 @@ public void transferLinkOwnership(String linkId, String fromUserId, String toUse public List getRelatedLinks(String linkId, String userId) { return linkRepository.getRelatedLinks(linkId, userId); } + + @Override + public @NonNull Link updateLink(@NonNull Link link) { + return java.util.Objects.requireNonNull(linkRepository.updateLink(link)); + } } diff --git a/src/main/java/it/robfrank/linklift/application/domain/service/DeleteLinkService.java b/src/main/java/it/robfrank/linklift/application/domain/service/DeleteLinkService.java new file mode 100644 index 00000000..cd078cae --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/domain/service/DeleteLinkService.java @@ -0,0 +1,28 @@ +package it.robfrank.linklift.application.domain.service; + +import it.robfrank.linklift.application.domain.exception.LinkNotFoundException; +import it.robfrank.linklift.application.port.in.DeleteLinkUseCase; +import it.robfrank.linklift.application.port.out.DeleteLinkPort; +import it.robfrank.linklift.application.port.out.LoadLinksPort; +import org.jspecify.annotations.NonNull; + +public class DeleteLinkService implements DeleteLinkUseCase { + + private final LoadLinksPort loadLinksPort; + private final DeleteLinkPort deleteLinkPort; + + public DeleteLinkService(LoadLinksPort loadLinksPort, DeleteLinkPort deleteLinkPort) { + this.loadLinksPort = loadLinksPort; + this.deleteLinkPort = deleteLinkPort; + } + + @Override + public void deleteLink(@NonNull String id, @NonNull String userId) { + // Check existence and ownership + if (!loadLinksPort.userOwnsLink(userId, id)) { + throw new LinkNotFoundException("Link not found or not owned by user"); + } + + deleteLinkPort.deleteLink(id); + } +} diff --git a/src/main/java/it/robfrank/linklift/application/domain/service/UpdateLinkService.java b/src/main/java/it/robfrank/linklift/application/domain/service/UpdateLinkService.java new file mode 100644 index 00000000..6b9f797c --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/domain/service/UpdateLinkService.java @@ -0,0 +1,39 @@ +package it.robfrank.linklift.application.domain.service; + +import it.robfrank.linklift.application.domain.exception.LinkNotFoundException; +import it.robfrank.linklift.application.domain.model.Link; +import it.robfrank.linklift.application.port.in.UpdateLinkCommand; +import it.robfrank.linklift.application.port.in.UpdateLinkUseCase; +import it.robfrank.linklift.application.port.out.LoadLinksPort; +import it.robfrank.linklift.application.port.out.UpdateLinkPort; +import org.jspecify.annotations.NonNull; + +public class UpdateLinkService implements UpdateLinkUseCase { + + private final LoadLinksPort loadLinksPort; + private final UpdateLinkPort updateLinkPort; + + public UpdateLinkService(LoadLinksPort loadLinksPort, UpdateLinkPort updateLinkPort) { + this.loadLinksPort = loadLinksPort; + this.updateLinkPort = updateLinkPort; + } + + @Override + public @NonNull Link updateLink(@NonNull UpdateLinkCommand command) { + // Check existence + Link existingLink = loadLinksPort.getLinkById(command.id()); + + // Check ownership + if (!loadLinksPort.userOwnsLink(command.userId(), command.id())) { + throw new LinkNotFoundException("Link not found or not owned by user"); + } + + // Update fields + String newTitle = command.title() != null ? command.title() : existingLink.title(); + String newDescription = command.description() != null ? command.description() : existingLink.description(); + + Link updatedLink = new Link(existingLink.id(), existingLink.url(), newTitle, newDescription, existingLink.extractedAt(), existingLink.contentType()); + + return updateLinkPort.updateLink(updatedLink); + } +} diff --git a/src/main/java/it/robfrank/linklift/application/port/in/DeleteLinkUseCase.java b/src/main/java/it/robfrank/linklift/application/port/in/DeleteLinkUseCase.java new file mode 100644 index 00000000..d1317818 --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/port/in/DeleteLinkUseCase.java @@ -0,0 +1,7 @@ +package it.robfrank.linklift.application.port.in; + +import org.jspecify.annotations.NonNull; + +public interface DeleteLinkUseCase { + void deleteLink(@NonNull String id, @NonNull String userId); +} diff --git a/src/main/java/it/robfrank/linklift/application/port/in/UpdateLinkCommand.java b/src/main/java/it/robfrank/linklift/application/port/in/UpdateLinkCommand.java new file mode 100644 index 00000000..93f2227a --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/port/in/UpdateLinkCommand.java @@ -0,0 +1,6 @@ +package it.robfrank.linklift.application.port.in; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public record UpdateLinkCommand(@NonNull String id, @Nullable String title, @Nullable String description, @NonNull String userId) {} diff --git a/src/main/java/it/robfrank/linklift/application/port/in/UpdateLinkUseCase.java b/src/main/java/it/robfrank/linklift/application/port/in/UpdateLinkUseCase.java new file mode 100644 index 00000000..f3f68c3b --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/port/in/UpdateLinkUseCase.java @@ -0,0 +1,9 @@ +package it.robfrank.linklift.application.port.in; + +import it.robfrank.linklift.application.domain.model.Link; +import org.jspecify.annotations.NonNull; + +public interface UpdateLinkUseCase { + @NonNull + Link updateLink(@NonNull UpdateLinkCommand command); +} diff --git a/src/main/java/it/robfrank/linklift/application/port/out/DeleteLinkPort.java b/src/main/java/it/robfrank/linklift/application/port/out/DeleteLinkPort.java new file mode 100644 index 00000000..90e6045d --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/port/out/DeleteLinkPort.java @@ -0,0 +1,7 @@ +package it.robfrank.linklift.application.port.out; + +import org.jspecify.annotations.NonNull; + +public interface DeleteLinkPort { + void deleteLink(@NonNull String id); +} diff --git a/src/main/java/it/robfrank/linklift/application/port/out/LoadLinksPort.java b/src/main/java/it/robfrank/linklift/application/port/out/LoadLinksPort.java index e72f82ce..c60aadad 100644 --- a/src/main/java/it/robfrank/linklift/application/port/out/LoadLinksPort.java +++ b/src/main/java/it/robfrank/linklift/application/port/out/LoadLinksPort.java @@ -9,4 +9,8 @@ public interface LoadLinksPort { LinkPage loadLinks(ListLinksQuery query); List getRelatedLinks(String linkId, String userId); + + Link getLinkById(String id); + + boolean userOwnsLink(String userId, String linkId); } diff --git a/src/main/java/it/robfrank/linklift/application/port/out/UpdateLinkPort.java b/src/main/java/it/robfrank/linklift/application/port/out/UpdateLinkPort.java new file mode 100644 index 00000000..60ac60a1 --- /dev/null +++ b/src/main/java/it/robfrank/linklift/application/port/out/UpdateLinkPort.java @@ -0,0 +1,9 @@ +package it.robfrank.linklift.application.port.out; + +import it.robfrank.linklift.application.domain.model.Link; +import org.jspecify.annotations.NonNull; + +public interface UpdateLinkPort { + @NonNull + Link updateLink(@NonNull Link link); +} diff --git a/src/main/java/it/robfrank/linklift/config/WebBuilder.java b/src/main/java/it/robfrank/linklift/config/WebBuilder.java index 84b392cb..df85bd16 100644 --- a/src/main/java/it/robfrank/linklift/config/WebBuilder.java +++ b/src/main/java/it/robfrank/linklift/config/WebBuilder.java @@ -115,6 +115,24 @@ public WebBuilder withGetRelatedLinksController(GetRelatedLinksController getRel return this; } + public WebBuilder withLinkManagementController(LinkController linkController) { + app.before("/api/v1/links/{id}", requireAuthentication); + + // Method-specific permission checks + app.before("/api/v1/links/{id}", ctx -> { + switch (ctx.method()) { + case PATCH -> RequirePermission.any(authorizationService, Role.Permissions.UPDATE_OWN_LINKS).handle(ctx); + case DELETE -> RequirePermission.any(authorizationService, Role.Permissions.DELETE_OWN_LINKS).handle(ctx); + default -> {} + } + }); + + app.patch("/api/v1/links/{id}", linkController::updateLink); + app.delete("/api/v1/links/{id}", linkController::deleteLink); + + return this; + } + public Javalin build() { return app; } diff --git a/webapp/src/components/LinkList.js b/webapp/src/components/LinkList.js index 2f7a6fa6..2e3f9a1e 100644 --- a/webapp/src/components/LinkList.js +++ b/webapp/src/components/LinkList.js @@ -24,9 +24,10 @@ import { DialogTitle, DialogContent, DialogActions, - DialogContentText + DialogContentText, + TextField } from "@mui/material"; -import { OpenInNew, Sort, Article, PlaylistAdd } from "@mui/icons-material"; +import { OpenInNew, Sort, Article, PlaylistAdd, Edit, Delete } from "@mui/icons-material"; import api from "../services/api"; import { ContentViewerModal } from "./ContentViewer/ContentViewerModal"; @@ -49,6 +50,16 @@ const LinkList = () => { const [selectedCollectionId, setSelectedCollectionId] = useState(""); const [addToCollectionLoading, setAddToCollectionLoading] = useState(false); + // Edit Link State + const [editLinkDialogOpen, setEditLinkDialogOpen] = useState(false); + const [linkToEdit, setLinkToEdit] = useState(null); + const [editTitle, setEditTitle] = useState(""); + const [editDescription, setEditDescription] = useState(""); + + // Delete Link State + const [deleteLinkDialogOpen, setDeleteLinkDialogOpen] = useState(false); + const [linkToDelete, setLinkToDelete] = useState(null); + const fetchLinks = async () => { try { setLoading(true); @@ -128,6 +139,45 @@ const LinkList = () => { } }; + const handleEditClick = (link) => { + setLinkToEdit(link); + setEditTitle(link.title || ""); + setEditDescription(link.description || ""); + setEditLinkDialogOpen(true); + }; + + const handleUpdateLink = async () => { + if (!linkToEdit) return; + try { + const response = await api.updateLink(linkToEdit.id, { title: editTitle, description: editDescription }); + const updatedLink = response.data; + setLinks((prevLinks) => prevLinks.map((link) => (link.id === updatedLink.id ? updatedLink : link))); + setEditLinkDialogOpen(false); + setLinkToEdit(null); + } catch (err) { + console.error("Error updating link:", err); + setError("Failed to update link."); + } + }; + + const handleDeleteClick = (link) => { + setLinkToDelete(link); + setDeleteLinkDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!linkToDelete) return; + try { + await api.deleteLink(linkToDelete.id); + setLinks((prevLinks) => prevLinks.filter((link) => link.id !== linkToDelete.id)); + setDeleteLinkDialogOpen(false); + setLinkToDelete(null); + } catch (err) { + console.error("Error deleting link:", err); + setError("Failed to delete link."); + } + }; + const formatDate = (dateString) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", @@ -257,6 +307,16 @@ const LinkList = () => { + + handleEditClick(link)} color="primary"> + + + + + handleDeleteClick(link)} color="error"> + + + @@ -324,6 +384,55 @@ const LinkList = () => { + + {/* Edit Link Dialog */} + setEditLinkDialogOpen(false)} maxWidth="sm" fullWidth> + Edit Link + + setEditTitle(e.target.value)} + sx={{ mb: 2 }} + /> + setEditDescription(e.target.value)} + /> + + + + + + + + {/* Delete Link Dialog */} + setDeleteLinkDialogOpen(false)}> + Delete Link + + + Are you sure you want to delete {linkToDelete?.title}? This action cannot be undone. + + + + + + + ); }; diff --git a/webapp/src/services/api.js b/webapp/src/services/api.js index d79b605c..2f0e86e9 100644 --- a/webapp/src/services/api.js +++ b/webapp/src/services/api.js @@ -47,6 +47,25 @@ const api = { } }, + updateLink: async (id, data) => { + try { + const response = await axios.patch(`${API_BASE_URL}/links/${id}`, data); + return response.data; + } catch (error) { + console.error(`Error updating link ${id}:`, error); + throw error; + } + }, + + deleteLink: async (id) => { + try { + await axios.delete(`${API_BASE_URL}/links/${id}`); + } catch (error) { + console.error(`Error deleting link ${id}:`, error); + throw error; + } + }, + // Collections createCollection: async (collectionData) => { try {