Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ The project implements a **Hexagonal Architecture** backend (Java 24, Javalin, A
- [x] Update `LinkList` to reflect changes immediately.
- **Content Domain**:
- **Backend**:
- [ ] Review `DownloadContentService` robustness (retries).
- [ ] Implement `DeleteContentUseCase`.
- [x] Review `DownloadContentService` robustness (retries) - Added 3 retries with 1s delay.
- [x] Implement `DeleteContentUseCase` - Full implementation with all layers.
- [x] Implement `RefreshContentUseCase` - Endpoint to re-trigger content download.
- **Frontend**:
- [ ] Improve `ContentViewer` error states.
- [ ] Add "Refresh Content" button (re-trigger download).
- [x] Improve `ContentViewer` error states - Enhanced error messages with specific error types.
- [x] Add "Refresh Content" button (re-trigger download) - Refresh button in modal header.

### Phase 3: Quality & Robustness (Medium Term)

Expand Down
20 changes: 14 additions & 6 deletions src/main/java/it/robfrank/linklift/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public static void main(String[] args) {
ArcadeLinkRepository linkRepository = new ArcadeLinkRepository(database, linkMapper);
LinkPersistenceAdapter linkPersistenceAdapter = new LinkPersistenceAdapter(linkRepository);

ExecutorService contentExtractionExecutor = Executors.newFixedThreadPool(1); // Single thread for content extraction
ExecutorService contentExtractionExecutor = Executors.newFixedThreadPool(1); // Single thread for content
// extraction

ArcadeContentRepository contentRepository = new ArcadeContentRepository(database);
ContentPersistenceAdapter contentPersistenceAdapter = new ContentPersistenceAdapter(contentRepository);
Expand Down Expand Up @@ -104,10 +105,12 @@ public static void main(String[] args) {
contentPersistenceAdapter,
eventPublisher,
contentExtractor,
contentSummarizer
contentSummarizer,
linkPersistenceAdapter
);
configureEventSubscribers(eventPublisher, downloadContentUseCase);
GetContentUseCase getContentUseCase = new GetContentService(contentPersistenceAdapter);
DeleteContentUseCase deleteContentUseCase = new DeleteContentService(contentPersistenceAdapter);

NewLinkUseCase newLinkUseCase = new NewLinkService(linkPersistenceAdapter, eventPublisher);
ListLinksUseCase listLinksUseCase = new ListLinksService(linkPersistenceAdapter, eventPublisher);
Expand Down Expand Up @@ -136,7 +139,8 @@ public static void main(String[] args) {
// Initialize controllers
NewLinkController newLinkController = new NewLinkController(newLinkUseCase);
ListLinksController listLinksController = new ListLinksController(listLinksUseCase);
GetContentController getContentController = new GetContentController(getContentUseCase);
GetContentController getContentController = new GetContentController(getContentUseCase, downloadContentUseCase);
DeleteContentController deleteContentController = new DeleteContentController(deleteContentUseCase);
AuthenticationController authenticationController = new AuthenticationController(userService, authenticationService, authenticationService);

// Build and start web application
Expand All @@ -146,6 +150,7 @@ public static void main(String[] args) {
.withLinkController(newLinkController)
.withListLinksController(listLinksController)
.withGetContentController(getContentController)
.withDeleteContentController(deleteContentController)
.withCollectionController(collectionController)
.withGetRelatedLinksController(getRelatedLinksController)
.build();
Expand Down Expand Up @@ -231,7 +236,8 @@ public Javalin start(int port) {
ArcadeLinkRepository linkRepository = new ArcadeLinkRepository(database, linkMapper);
LinkPersistenceAdapter linkPersistenceAdapter = new LinkPersistenceAdapter(linkRepository);

ExecutorService contentExtractionExecutor = Executors.newFixedThreadPool(1); // Single thread for content extraction
ExecutorService contentExtractionExecutor = Executors.newFixedThreadPool(1); // Single thread for content
// extraction

ArcadeContentRepository contentRepository = new ArcadeContentRepository(database);
ContentPersistenceAdapter contentPersistenceAdapter = new ContentPersistenceAdapter(contentRepository);
Expand Down Expand Up @@ -283,12 +289,14 @@ public Javalin start(int port) {
contentPersistenceAdapter,
eventPublisher,
contentExtractor,
contentSummarizer
contentSummarizer,
linkPersistenceAdapter
);

configureEventSubscribers(eventPublisher, downloadContentUseCase);

GetContentUseCase getContentUseCase = new GetContentService(contentPersistenceAdapter);
DeleteContentUseCase deleteContentUseCase = new DeleteContentService(contentPersistenceAdapter);

NewLinkUseCase newLinkUseCase = new NewLinkService(linkPersistenceAdapter, eventPublisher);
ListLinksUseCase listLinksUseCase = new ListLinksService(linkPersistenceAdapter, eventPublisher);
Expand Down Expand Up @@ -317,7 +325,7 @@ public Javalin start(int port) {
// Initialize controllers
NewLinkController newLinkController = new NewLinkController(newLinkUseCase);
ListLinksController listLinksController = new ListLinksController(listLinksUseCase);
GetContentController getContentController = new GetContentController(getContentUseCase);
GetContentController getContentController = new GetContentController(getContentUseCase, downloadContentUseCase);
AuthenticationController authenticationController = new AuthenticationController(userService, authenticationService, authenticationService);

// Initialize Link Management
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package it.robfrank.linklift.adapter.in.web;

import io.javalin.http.Context;
import it.robfrank.linklift.application.port.in.DeleteContentCommand;
import it.robfrank.linklift.application.port.in.DeleteContentUseCase;
import org.jspecify.annotations.NonNull;

public class DeleteContentController {

private final DeleteContentUseCase deleteContentUseCase;

public DeleteContentController(@NonNull DeleteContentUseCase deleteContentUseCase) {
this.deleteContentUseCase = deleteContentUseCase;
}

public void deleteContent(@NonNull Context ctx) {
String linkId = ctx.pathParam("linkId");
DeleteContentCommand command = new DeleteContentCommand(linkId);
deleteContentUseCase.deleteContent(command);
ctx.status(204);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import io.javalin.http.Context;
import it.robfrank.linklift.application.domain.exception.ContentNotFoundException;
import it.robfrank.linklift.application.domain.model.Content;
import it.robfrank.linklift.application.port.in.DownloadContentUseCase;
import it.robfrank.linklift.application.port.in.GetContentQuery;
import it.robfrank.linklift.application.port.in.GetContentUseCase;
import org.jspecify.annotations.NonNull;

public class GetContentController {

private final GetContentUseCase getContentUseCase;
private final DownloadContentUseCase downloadContentUseCase;

public GetContentController(@NonNull GetContentUseCase getContentUseCase) {
public GetContentController(@NonNull GetContentUseCase getContentUseCase, @NonNull DownloadContentUseCase downloadContentUseCase) {
this.getContentUseCase = getContentUseCase;
this.downloadContentUseCase = downloadContentUseCase;
}

public void getContent(@NonNull Context ctx) {
Expand All @@ -23,5 +26,13 @@ public void getContent(@NonNull Context ctx) {
ctx.json(new ContentResponse(content, "Content retrieved successfully"));
}

public void refreshContent(@NonNull Context ctx) {
String linkId = ctx.pathParam("linkId");
downloadContentUseCase.refreshContent(linkId);
ctx.status(202).json(new MessageResponse("Content refresh triggered"));
}

public record ContentResponse(@NonNull Content data, @NonNull String message) {}

public record MessageResponse(@NonNull String message) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,14 @@ public void createHasContentEdge(@NonNull String linkId, @NonNull String content
throw new DatabaseException("Failed to create HasContent edge: " + e.getMessage(), e);
}
}

public void deleteByLinkId(@NonNull String linkId) {
try {
database.transaction(() -> {
database.command("sql", "DELETE VERTEX Content WHERE linkId = ?", linkId);
});
} catch (Exception e) {
throw new DatabaseException("Failed to delete content by link ID: " + e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ public void createHasContentEdge(@NonNull String linkId, @NonNull String content
public @NonNull Optional<Content> findContentById(@NonNull String contentId) {
return repository.findById(contentId);
}

@Override
public void deleteContentByLinkId(@NonNull String linkId) {
repository.deleteByLinkId(linkId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package it.robfrank.linklift.application.domain.service;

import it.robfrank.linklift.application.port.in.DeleteContentCommand;
import it.robfrank.linklift.application.port.in.DeleteContentUseCase;
import it.robfrank.linklift.application.port.out.SaveContentPort;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DeleteContentService implements DeleteContentUseCase {

private static final Logger logger = LoggerFactory.getLogger(DeleteContentService.class);

private final SaveContentPort saveContentPort;

public DeleteContentService(@NonNull SaveContentPort saveContentPort) {
this.saveContentPort = saveContentPort;
}

@Override
public void deleteContent(@NonNull DeleteContentCommand command) {
logger.info("Deleting content for link: {}", command.linkId());
saveContentPort.deleteContentByLinkId(command.linkId());
logger.info("Content deleted for link: {}", command.linkId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import it.robfrank.linklift.application.domain.exception.ContentDownloadException;
import it.robfrank.linklift.application.domain.model.Content;
import it.robfrank.linklift.application.domain.model.DownloadStatus;
import it.robfrank.linklift.application.domain.model.Link;
import it.robfrank.linklift.application.port.in.DownloadContentCommand;
import it.robfrank.linklift.application.port.in.DownloadContentUseCase;
import it.robfrank.linklift.application.port.out.*;
Expand All @@ -29,36 +30,44 @@ public class DownloadContentService implements DownloadContentUseCase {
private final DomainEventPublisher eventPublisher;
private final ContentExtractorPort contentExtractor;
private final ContentSummarizerPort contentSummarizer;
private final LoadLinksPort loadLinksPort;

public DownloadContentService(
@NonNull ContentDownloaderPort contentDownloader,
@NonNull SaveContentPort saveContentPort,
@NonNull DomainEventPublisher eventPublisher,
@NonNull ContentExtractorPort contentExtractor,
@NonNull ContentSummarizerPort contentSummarizer
@NonNull ContentSummarizerPort contentSummarizer,
@NonNull LoadLinksPort loadLinksPort
) {
this.contentDownloader = contentDownloader;
this.saveContentPort = saveContentPort;
this.eventPublisher = eventPublisher;
this.contentExtractor = contentExtractor;
this.contentSummarizer = contentSummarizer;
this.loadLinksPort = loadLinksPort;
}

@Override
public void refreshContent(@NonNull String linkId) {
Link link = loadLinksPort.getLinkById(linkId);
if (link != null) {
downloadContentAsync(new DownloadContentCommand(linkId, link.url()));
}
}

@Override
public void downloadContentAsync(@NonNull DownloadContentCommand command) {
// Publish download started event
// String id = command.getLink().id();
// String url = command.getLink().url();
String id = command.linkId();
String url = command.url();

eventPublisher.publish(new ContentDownloadStartedEvent(id, url));

logger.info("Starting async content download for link: {}, url: {}", id, url);

// Start async download
contentDownloader
.downloadContent(url)
// Start async download with retries
downloadWithRetry(url, 3)
.thenAccept(downloadedContent -> {
try {
// Validate content size
Expand Down Expand Up @@ -132,6 +141,27 @@ public void downloadContentAsync(@NonNull DownloadContentCommand command) {
});
}

private java.util.concurrent.CompletableFuture<ContentDownloaderPort.DownloadedContent> downloadWithRetry(String url, int retries) {
return contentDownloader
.downloadContent(url)
.handle((res, ex) -> {
if (ex == null) {
return java.util.concurrent.CompletableFuture.completedFuture(res);
} else {
if (retries > 0) {
logger.warn("Download failed for url: {}. Retrying... ({} attempts remaining)", url, retries);
return java.util.concurrent.CompletableFuture.runAsync(
() -> {},
java.util.concurrent.CompletableFuture.delayedExecutor(1, java.util.concurrent.TimeUnit.SECONDS)
).thenCompose(v -> downloadWithRetry(url, retries - 1));
} else {
return java.util.concurrent.CompletableFuture.<ContentDownloaderPort.DownloadedContent>failedFuture(ex);
}
}
})
.thenCompose(java.util.function.Function.identity());
}

private LocalDateTime parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) return null;
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package it.robfrank.linklift.application.port.in;

import org.jspecify.annotations.NonNull;

public record DeleteContentCommand(@NonNull String linkId) {
public DeleteContentCommand {
if (linkId == null || linkId.isBlank()) {
throw new IllegalArgumentException("LinkId cannot be null or empty");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package it.robfrank.linklift.application.port.in;

import org.jspecify.annotations.NonNull;

public interface DeleteContentUseCase {
void deleteContent(@NonNull DeleteContentCommand command);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@

public interface DownloadContentUseCase {
void downloadContentAsync(@NonNull DownloadContentCommand command);

void refreshContent(@NonNull String linkId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ public interface SaveContentPort {
Content saveContent(@NonNull Content content);

void createHasContentEdge(@NonNull String linkId, @NonNull String contentId);

void deleteContentByLinkId(@NonNull String linkId);
}
23 changes: 22 additions & 1 deletion src/main/java/it/robfrank/linklift/config/WebBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,29 @@ public WebBuilder withListLinksController(ListLinksController listLinksControlle

public WebBuilder withGetContentController(GetContentController getContentController) {
app.before("/api/v1/links/{linkId}/content", requireAuthentication);
app.before("/api/v1/links/{linkId}/content", RequirePermission.any(authorizationService, Role.Permissions.READ_OWN_LINKS));
app.before("/api/v1/links/{linkId}/content", ctx -> {
if (ctx.method().equals(io.javalin.http.HandlerType.GET)) {
RequirePermission.any(authorizationService, Role.Permissions.READ_OWN_LINKS).handle(ctx);
}
});
app.get("/api/v1/links/{linkId}/content", getContentController::getContent);

app.before("/api/v1/links/{linkId}/content/refresh", ctx -> {
if (ctx.method().equals(io.javalin.http.HandlerType.POST)) {
RequirePermission.any(authorizationService, Role.Permissions.UPDATE_OWN_LINKS).handle(ctx);
}
});
app.post("/api/v1/links/{linkId}/content/refresh", getContentController::refreshContent);
return this;
}

public WebBuilder withDeleteContentController(DeleteContentController deleteContentController) {
app.before("/api/v1/links/{linkId}/content", ctx -> {
if (ctx.method().equals(io.javalin.http.HandlerType.DELETE)) {
RequirePermission.any(authorizationService, Role.Permissions.DELETE_OWN_LINKS).handle(ctx);
}
});
app.delete("/api/v1/links/{linkId}/content", deleteContentController::deleteContent);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import it.robfrank.linklift.application.domain.exception.ContentNotFoundException;
import it.robfrank.linklift.application.domain.model.Content;
import it.robfrank.linklift.application.domain.model.DownloadStatus;
import it.robfrank.linklift.application.port.in.DownloadContentUseCase;
import it.robfrank.linklift.application.port.in.GetContentQuery;
import it.robfrank.linklift.application.port.in.GetContentUseCase;
import java.time.LocalDateTime;
Expand All @@ -23,6 +24,9 @@ class GetContentControllerTest {
@Mock
private GetContentUseCase getContentUseCase;

@Mock
private DownloadContentUseCase downloadContentUseCase;

@Mock
private Context context;

Expand All @@ -31,7 +35,20 @@ class GetContentControllerTest {
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
getContentController = new GetContentController(getContentUseCase);
getContentController = new GetContentController(getContentUseCase, downloadContentUseCase);
}

@Test
void refreshContent_shouldTriggerDownload() {
String linkId = "link-123";
when(context.pathParam("linkId")).thenReturn(linkId);
when(context.status(anyInt())).thenReturn(context);

getContentController.refreshContent(context);

verify(downloadContentUseCase).refreshContent(linkId);
verify(context).status(202);
verify(context).json(any(GetContentController.MessageResponse.class));
}

@Test
Expand Down
Loading
Loading