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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.autoapplicant.adapter.persistence.adapter;

import com.autoapplicant.adapter.persistence.repository.ApplicationStatusEventJpaRepository;
import com.autoapplicant.adapter.persistence.repository.GeneratedDocumentJpaRepository;
import com.autoapplicant.adapter.persistence.repository.OutreachContactJpaRepository;
import com.autoapplicant.port.out.analytics.JobSearchActivityPort;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Component;

@Component
public class JobSearchActivityPersistenceAdapter implements JobSearchActivityPort {

private final ApplicationStatusEventJpaRepository statusEvents;
private final GeneratedDocumentJpaRepository documents;
private final OutreachContactJpaRepository outreach;

public JobSearchActivityPersistenceAdapter(ApplicationStatusEventJpaRepository statusEvents,
GeneratedDocumentJpaRepository documents,
OutreachContactJpaRepository outreach) {
this.statusEvents = statusEvents;
this.documents = documents;
this.outreach = outreach;
}

@Override
public List<Instant> findActivitySince(UUID userId, Instant since) {
List<Instant> activity = new ArrayList<>(statusEvents.findOccurredAtSince(userId, since));
activity.addAll(documents.findCreatedAtSince(userId, since));
activity.addAll(outreach.findCreatedAtSince(userId, since));
activity.addAll(outreach.findContactedAtSince(userId, since));
return activity;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.autoapplicant.adapter.persistence.repository;

import com.autoapplicant.adapter.persistence.entity.ApplicationStatusEventEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ApplicationStatusEventJpaRepository extends JpaRepository<ApplicationStatusEventEntity, UUID> {
List<ApplicationStatusEventEntity> findByUserIdOrderByOccurredAtAsc(UUID userId);

List<ApplicationStatusEventEntity> findByApplicationIdAndUserIdOrderByOccurredAtAsc(UUID applicationId, UUID userId);

@Query("SELECT e.occurredAt FROM ApplicationStatusEventEntity e WHERE e.userId = :userId AND e.occurredAt >= :since")
List<Instant> findOccurredAtSince(UUID userId, Instant since);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.autoapplicant.adapter.persistence.repository;

import com.autoapplicant.adapter.persistence.entity.GeneratedDocumentEntity;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
Expand All @@ -15,4 +16,7 @@ public interface GeneratedDocumentJpaRepository extends JpaRepository<GeneratedD

@Query("SELECT e FROM GeneratedDocumentEntity e WHERE e.userId = :userId AND e.documentType = :documentType ORDER BY e.createdAt DESC")
List<GeneratedDocumentEntity> findRecentByUserIdAndType(UUID userId, String documentType, Pageable pageable);

@Query("SELECT e.createdAt FROM GeneratedDocumentEntity e WHERE e.userId = :userId AND e.createdAt >= :since")
List<Instant> findCreatedAtSince(UUID userId, Instant since);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package com.autoapplicant.adapter.persistence.repository;

import com.autoapplicant.adapter.persistence.entity.OutreachContactEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface OutreachContactJpaRepository extends JpaRepository<OutreachContactEntity, UUID> {
List<OutreachContactEntity> findByUserId(UUID userId);
Optional<OutreachContactEntity> findByUserIdAndCompanyId(UUID userId, UUID companyId);

@Query("SELECT e.createdAt FROM OutreachContactEntity e WHERE e.userId = :userId AND e.createdAt >= :since")
List<Instant> findCreatedAtSince(UUID userId, Instant since);

@Query("SELECT e.contactedAt FROM OutreachContactEntity e WHERE e.userId = :userId AND e.contactedAt >= :since")
List<Instant> findContactedAtSince(UUID userId, Instant since);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.autoapplicant.adapter.web.controller;

import com.autoapplicant.adapter.security.SecurityContextHelper;
import com.autoapplicant.domain.analytics.ActivityStreak;
import com.autoapplicant.domain.analytics.ApplicationMetrics;
import com.autoapplicant.domain.analytics.DetailedMetrics;
import com.autoapplicant.domain.analytics.FunnelVelocity;
import com.autoapplicant.domain.analytics.WeeklyTrend;
import com.autoapplicant.port.in.analytics.GetActivityStreakUseCase;
import com.autoapplicant.port.in.analytics.GetApplicationMetricsUseCase;
import com.autoapplicant.port.in.analytics.GetDetailedMetricsUseCase;
import com.autoapplicant.port.in.analytics.GetFunnelVelocityUseCase;
import com.autoapplicant.port.in.analytics.GetWeeklyTrendUseCase;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.DateTimeException;
import java.time.ZoneId;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -23,17 +28,20 @@ public class AnalyticsController {
private final GetDetailedMetricsUseCase getDetailedMetrics;
private final GetWeeklyTrendUseCase getWeeklyTrend;
private final GetFunnelVelocityUseCase getFunnelVelocity;
private final GetActivityStreakUseCase getActivityStreak;
private final SecurityContextHelper secCtx;

public AnalyticsController(GetApplicationMetricsUseCase getMetrics,
GetDetailedMetricsUseCase getDetailedMetrics,
GetWeeklyTrendUseCase getWeeklyTrend,
GetFunnelVelocityUseCase getFunnelVelocity,
GetActivityStreakUseCase getActivityStreak,
SecurityContextHelper secCtx) {
this.getMetrics = getMetrics;
this.getDetailedMetrics = getDetailedMetrics;
this.getWeeklyTrend = getWeeklyTrend;
this.getFunnelVelocity = getFunnelVelocity;
this.getActivityStreak = getActivityStreak;
this.secCtx = secCtx;
}

Expand All @@ -60,4 +68,19 @@ public ResponseEntity<WeeklyTrend> weeklyTrend() {
public ResponseEntity<FunnelVelocity> funnelVelocity() {
return ResponseEntity.ok(getFunnelVelocity.getFunnelVelocity(secCtx.getCurrentUserId()));
}

@Operation(summary = "Consecutive days with job-search activity, counted in the caller's time zone")
@ApiResponse(responseCode = "400", description = "Unknown time zone")
@GetMapping("/streak")
public ResponseEntity<ActivityStreak> streak(@RequestParam(defaultValue = "UTC") String zone) {
return ResponseEntity.ok(getActivityStreak.getActivityStreak(secCtx.getCurrentUserId(), parseZone(zone)));
}

private static ZoneId parseZone(String zone) {
try {
return ZoneId.of(zone);
} catch (DateTimeException e) {
throw new IllegalArgumentException("Unknown time zone: " + zone, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.autoapplicant.domain.analytics;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

/**
* Consecutive calendar days, in the user's own time zone, with at least one bit of job-search
* activity. A day with nothing yet doesn't break the run until it's over, so a streak that
* ended yesterday still counts as ongoing today. {@code lastWeek} is the activity count per
* day for the seven days ending today, oldest first.
*/
public record ActivityStreak(int days, List<Integer> lastWeek) {

static final int WEEK = 7;

public static ActivityStreak of(Collection<Instant> activity, ZoneId zone, LocalDate today) {
Map<LocalDate, Long> perDay = activity.stream()
.collect(Collectors.groupingBy(at -> LocalDate.ofInstant(at, zone), Collectors.counting()));
return new ActivityStreak(runEndingAt(perDay, today), lastWeek(perDay, today));
}

private static int runEndingAt(Map<LocalDate, Long> perDay, LocalDate today) {
LocalDate day = perDay.containsKey(today) ? today : today.minusDays(1);
int days = 0;
while (perDay.containsKey(day)) {
days++;
day = day.minusDays(1);
}
return days;
}

private static List<Integer> lastWeek(Map<LocalDate, Long> perDay, LocalDate today) {
Function<LocalDate, Integer> count = day -> perDay.getOrDefault(day, 0L).intValue();
return IntStream.range(0, WEEK)
.mapToObj(i -> today.minusDays(WEEK - 1L - i))
.map(count)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.autoapplicant.port.in.analytics;

import com.autoapplicant.domain.analytics.ActivityStreak;
import java.time.ZoneId;
import java.util.UUID;

public interface GetActivityStreakUseCase {
ActivityStreak getActivityStreak(UUID userId, ZoneId zone);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.autoapplicant.port.out.analytics;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

/** When the user did something for their job search: application moves, generated documents, outreach. */
public interface JobSearchActivityPort {
List<Instant> findActivitySince(UUID userId, Instant since);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.autoapplicant.usecase.analytics;

import com.autoapplicant.domain.analytics.ActivityStreak;
import com.autoapplicant.port.in.analytics.GetActivityStreakUseCase;
import com.autoapplicant.port.out.analytics.JobSearchActivityPort;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;

@Service
public class ActivityStreakService implements GetActivityStreakUseCase {

/** How far back to look. Streaks longer than this are reported as this long. */
static final int LOOKBACK_DAYS = 400;

private final JobSearchActivityPort activity;
private final Clock clock;

public ActivityStreakService(JobSearchActivityPort activity, Clock clock) {
this.activity = activity;
this.clock = clock;
}

@Override
public ActivityStreak getActivityStreak(UUID userId, ZoneId zone) {
LocalDate today = LocalDate.ofInstant(clock.instant(), zone);
Instant since = today.minusDays(LOOKBACK_DAYS).atStartOfDay(zone).toInstant();
List<Instant> events = activity.findActivitySince(userId, since);
return ActivityStreak.of(events, zone, today);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.autoapplicant.adapter.web.controller;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.autoapplicant.adapter.security.SecurityContextHelper;
import com.autoapplicant.domain.analytics.ActivityStreak;
import com.autoapplicant.port.in.analytics.GetActivityStreakUseCase;
import com.autoapplicant.port.in.analytics.GetApplicationMetricsUseCase;
import com.autoapplicant.port.in.analytics.GetDetailedMetricsUseCase;
import com.autoapplicant.port.in.analytics.GetFunnelVelocityUseCase;
import com.autoapplicant.port.in.analytics.GetWeeklyTrendUseCase;
import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase;
import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase;
import com.google.firebase.auth.FirebaseAuth;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(controllers = AnalyticsController.class)
@AutoConfigureMockMvc(addFilters = false)
class AnalyticsControllerStreakTest {

@Autowired MockMvc mvc;

@MockitoBean GetActivityStreakUseCase getActivityStreak;
@MockitoBean GetApplicationMetricsUseCase getMetrics;
@MockitoBean GetDetailedMetricsUseCase getDetailedMetrics;
@MockitoBean GetWeeklyTrendUseCase getWeeklyTrend;
@MockitoBean GetFunnelVelocityUseCase getFunnelVelocity;
@MockitoBean SecurityContextHelper secCtx;
@MockitoBean ProvisionFirebaseUserUseCase provisionUser;
@MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser;
@MockitoBean FirebaseAuth firebaseAuth;

UUID userId = UUID.randomUUID();

@BeforeEach
void setUp() {
when(secCtx.getCurrentUserId()).thenReturn(userId);
}

@Test
void counts_in_the_requested_zone() throws Exception {
when(getActivityStreak.getActivityStreak(userId, ZoneId.of("Europe/Copenhagen")))
.thenReturn(new ActivityStreak(4, List.of(0, 0, 0, 1, 1, 2, 1)));

mvc.perform(get("/api/v1/analytics/streak").param("zone", "Europe/Copenhagen"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.days").value(4))
.andExpect(jsonPath("$.lastWeek[5]").value(2));
}

@Test
void defaults_to_utc_without_a_zone() throws Exception {
when(getActivityStreak.getActivityStreak(userId, ZoneId.of("UTC")))
.thenReturn(new ActivityStreak(0, List.of(0, 0, 0, 0, 0, 0, 0)));

mvc.perform(get("/api/v1/analytics/streak"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.days").value(0));
}

@Test
void rejects_an_unknown_zone() throws Exception {
mvc.perform(get("/api/v1/analytics/streak").param("zone", "Mars/Olympus_Mons"))
.andExpect(status().isBadRequest());

verify(getActivityStreak, never()).getActivityStreak(any(), any());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ void get_by_id_returns_404_when_missing() throws Exception {

@Test
void get_research_returns_empty_notes_when_none_saved() throws Exception {
when(research.getResearch(companyId)).thenReturn(Optional.empty());
when(research.getResearch(userId, companyId)).thenReturn(Optional.empty());

mvc.perform(get("/api/v1/companies/{id}/research", companyId))
.andExpect(status().isOk())
Expand All @@ -102,7 +102,7 @@ void get_research_returns_empty_notes_when_none_saved() throws Exception {

@Test
void save_research_returns_saved_notes() throws Exception {
when(research.saveResearch(companyId, "They ship weekly"))
when(research.saveResearch(userId, companyId, "They ship weekly"))
.thenReturn(Optional.of(new CompanyResearch("They ship weekly", Instant.now())));

mvc.perform(put("/api/v1/companies/{id}/research", companyId)
Expand All @@ -116,7 +116,7 @@ void save_research_returns_saved_notes() throws Exception {

@Test
void save_research_returns_404_for_unknown_company() throws Exception {
when(research.saveResearch(eq(companyId), any())).thenReturn(Optional.empty());
when(research.saveResearch(eq(userId), eq(companyId), any())).thenReturn(Optional.empty());

mvc.perform(put("/api/v1/companies/{id}/research", companyId)
.contentType(MediaType.APPLICATION_JSON)
Expand Down
Loading
Loading