From 7dfec6118e5baae6b3698cb2e96b7f1f54779a15 Mon Sep 17 00:00:00 2001 From: CMaintz Date: Thu, 1 Oct 2026 10:42:17 +0200 Subject: [PATCH 1/2] fix(backend): make company research notes per user Notes lived on the shared companies row, so every user read and overwrote the same text, and it also grounded everyone else's cover letters. They now live in user_company_notes keyed by (user_id, company_id). --- .../adapter/CompanyPersistenceAdapter.java | 18 --- .../CompanyResearchPersistenceAdapter.java | 44 ++++++++ .../persistence/entity/CompanyEntity.java | 13 --- .../entity/UserCompanyNoteEntity.java | 54 +++++++++ .../UserCompanyNoteJpaRepository.java | 8 ++ .../web/controller/CompanyController.java | 11 +- .../company/ManageCompanyResearchUseCase.java | 9 +- .../out/company/CompanyRepositoryPort.java | 5 - .../CompanyResearchRepositoryPort.java | 14 +++ .../ai/ApplicationPromptAssembler.java | 2 +- .../usecase/ai/CompanyGroundingService.java | 17 ++- .../usecase/company/CompanyService.java | 16 ++- .../db/migration/V036__user_company_notes.sql | 44 ++++++++ .../persistence/PostgresIntegrationTest.java | 13 ++- ...CompanyResearchPersistenceAdapterTest.java | 82 ++++++++++++++ .../UserCompanyNotesMigrationTest.java | 106 ++++++++++++++++++ .../ai/ApplicationPromptAssemblerTest.java | 24 +++- .../ai/CompanyGroundingServiceTest.java | 40 +++++++ .../usecase/company/CompanyServiceTest.java | 63 ++++++++--- .../InMemoryCompanyResearchRepository.java | 30 +++++ 20 files changed, 537 insertions(+), 76 deletions(-) create mode 100644 backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java create mode 100644 backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java create mode 100644 backend/src/main/java/com/autoapplicant/adapter/persistence/repository/UserCompanyNoteJpaRepository.java create mode 100644 backend/src/main/java/com/autoapplicant/port/out/company/CompanyResearchRepositoryPort.java create mode 100644 backend/src/main/resources/db/migration/V036__user_company_notes.sql create mode 100644 backend/src/test/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapterTest.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/persistence/migration/UserCompanyNotesMigrationTest.java create mode 100644 backend/src/test/java/com/autoapplicant/usecase/ai/CompanyGroundingServiceTest.java create mode 100644 backend/src/test/java/com/autoapplicant/usecase/company/InMemoryCompanyResearchRepository.java diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyPersistenceAdapter.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyPersistenceAdapter.java index 65cb20d6..634adb1c 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyPersistenceAdapter.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyPersistenceAdapter.java @@ -3,7 +3,6 @@ import com.autoapplicant.adapter.persistence.entity.CompanyEntity; import com.autoapplicant.adapter.persistence.repository.CompanyJpaRepository; import com.autoapplicant.domain.company.Company; -import com.autoapplicant.domain.company.CompanyResearch; import com.autoapplicant.domain.company.CompanySize; import com.autoapplicant.port.out.company.CompanyRepositoryPort; import java.util.List; @@ -81,23 +80,6 @@ public void saveFacts(UUID companyId, String facts) { }); } - @Override - public Optional findResearch(UUID companyId) { - return repo.findById(companyId) - .filter(e -> e.getResearchNotes() != null && !e.getResearchNotes().isBlank()) - .map(e -> new CompanyResearch(e.getResearchNotes(), e.getResearchNotesUpdatedAt())); - } - - @Override - @org.springframework.transaction.annotation.Transactional - public void saveResearch(UUID companyId, String notes) { - repo.findById(companyId).ifPresent(e -> { - e.setResearchNotes(notes != null && !notes.isBlank() ? notes : null); - e.setResearchNotesUpdatedAt(java.time.Instant.now()); - repo.save(e); - }); - } - private Company toDomain(CompanyEntity e) { CompanySize size = e.getSizeRange() != null ? CompanySize.valueOf(e.getSizeRange()) : null; diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java new file mode 100644 index 00000000..6f8fcdfc --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java @@ -0,0 +1,44 @@ +package com.autoapplicant.adapter.persistence.adapter; + +import com.autoapplicant.adapter.persistence.entity.UserCompanyNoteEntity; +import com.autoapplicant.adapter.persistence.repository.UserCompanyNoteJpaRepository; +import com.autoapplicant.domain.company.CompanyResearch; +import com.autoapplicant.port.out.company.CompanyResearchRepositoryPort; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class CompanyResearchPersistenceAdapter implements CompanyResearchRepositoryPort { + + private final UserCompanyNoteJpaRepository repo; + + public CompanyResearchPersistenceAdapter(UserCompanyNoteJpaRepository repo) { + this.repo = repo; + } + + @Override + public Optional findResearch(UUID userId, UUID companyId) { + return repo.findById(new UserCompanyNoteEntity.Key(userId, companyId)) + .map(e -> new CompanyResearch(e.getNotes(), e.getUpdatedAt())); + } + + @Override + @Transactional + public void saveResearch(UUID userId, UUID companyId, String notes) { + UserCompanyNoteEntity.Key key = new UserCompanyNoteEntity.Key(userId, companyId); + if (notes == null || notes.isBlank()) { + repo.deleteById(key); + return; + } + UserCompanyNoteEntity entity = repo.findById(key).orElseGet(() -> { + UserCompanyNoteEntity created = new UserCompanyNoteEntity(); + created.setUserId(userId); + created.setCompanyId(companyId); + return created; + }); + entity.setNotes(notes); + repo.saveAndFlush(entity); + } +} diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/CompanyEntity.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/CompanyEntity.java index 4c991ef7..da6982b4 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/CompanyEntity.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/CompanyEntity.java @@ -46,13 +46,6 @@ public class CompanyEntity { @Column(name = "facts_researched_at") private Instant factsResearchedAt; - /** User-pasted company research, for cover-letter grounding (semi-trusted; distinct from facts). */ - @Column(name = "research_notes", columnDefinition = "text") - private String researchNotes; - - @Column(name = "research_notes_updated_at") - private Instant researchNotesUpdatedAt; - @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -90,12 +83,6 @@ public class CompanyEntity { public void setResearchedFacts(String researchedFacts) { this.researchedFacts = researchedFacts; } public Instant getFactsResearchedAt() { return factsResearchedAt; } public void setFactsResearchedAt(Instant factsResearchedAt) { this.factsResearchedAt = factsResearchedAt; } - public String getResearchNotes() { return researchNotes; } - public void setResearchNotes(String researchNotes) { this.researchNotes = researchNotes; } - public Instant getResearchNotesUpdatedAt() { return researchNotesUpdatedAt; } - public void setResearchNotesUpdatedAt(Instant researchNotesUpdatedAt) { - this.researchNotesUpdatedAt = researchNotesUpdatedAt; - } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } } diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java new file mode 100644 index 00000000..914e010b --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java @@ -0,0 +1,54 @@ +package com.autoapplicant.adapter.persistence.entity; + +import jakarta.persistence.*; +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** One user's research notes on one company. */ +@Entity +@Table(name = "user_company_notes") +@IdClass(UserCompanyNoteEntity.Key.class) +public class UserCompanyNoteEntity { + + @Id + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Id + @Column(name = "company_id", nullable = false) + private UUID companyId; + + @Column(nullable = false, columnDefinition = "text") + private String notes; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist @PreUpdate + void touch() { updatedAt = Instant.now(); } + + public UUID getUserId() { return userId; } + public void setUserId(UUID userId) { this.userId = userId; } + public UUID getCompanyId() { return companyId; } + public void setCompanyId(UUID companyId) { this.companyId = companyId; } + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + public Instant getUpdatedAt() { return updatedAt; } + + public static class Key implements Serializable { + private UUID userId; + private UUID companyId; + + public Key() {} + public Key(UUID userId, UUID companyId) { this.userId = userId; this.companyId = companyId; } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Key key)) return false; + return Objects.equals(userId, key.userId) && Objects.equals(companyId, key.companyId); + } + @Override public int hashCode() { return Objects.hash(userId, companyId); } + } +} diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/UserCompanyNoteJpaRepository.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/UserCompanyNoteJpaRepository.java new file mode 100644 index 00000000..1ddc880e --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/UserCompanyNoteJpaRepository.java @@ -0,0 +1,8 @@ +package com.autoapplicant.adapter.persistence.repository; + +import com.autoapplicant.adapter.persistence.entity.UserCompanyNoteEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserCompanyNoteJpaRepository + extends JpaRepository { +} diff --git a/backend/src/main/java/com/autoapplicant/adapter/web/controller/CompanyController.java b/backend/src/main/java/com/autoapplicant/adapter/web/controller/CompanyController.java index 35314132..1229d2dd 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/web/controller/CompanyController.java +++ b/backend/src/main/java/com/autoapplicant/adapter/web/controller/CompanyController.java @@ -112,21 +112,22 @@ public ResponseEntity getById(@PathVariable UUID id) { .orElse(ResponseEntity.notFound().build()); } - @Operation(summary = "The candidate's research notes for a company", + @Operation(summary = "The current user's research notes for a company", description = "Free-text research (typically from an external agent) used to ground " - + "cover-letter references. Notes are null when none have been saved.") + + "cover-letter references. Private to the user who saved them. Notes are null " + + "when none have been saved.") @GetMapping("/api/v1/companies/{id}/research") public ResponseEntity getResearch(@PathVariable UUID id) { - return ResponseEntity.ok(research.getResearch(id).orElse(new CompanyResearch(null, null))); + return ResponseEntity.ok(research.getResearch(secCtx.getCurrentUserId(), id).orElse(new CompanyResearch(null, null))); } - @Operation(summary = "Save the candidate's research notes for a company", + @Operation(summary = "Save the current user's research notes for a company", description = "A blank body clears the notes.") @ApiResponses(@ApiResponse(responseCode = "404", description = "Company not found")) @PutMapping("/api/v1/companies/{id}/research") public ResponseEntity saveResearch(@PathVariable UUID id, @RequestBody SaveCompanyResearchRequest req) { - return research.saveResearch(id, req.notes()) + return research.saveResearch(secCtx.getCurrentUserId(), id, req.notes()) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } diff --git a/backend/src/main/java/com/autoapplicant/port/in/company/ManageCompanyResearchUseCase.java b/backend/src/main/java/com/autoapplicant/port/in/company/ManageCompanyResearchUseCase.java index 88422f73..dc16be34 100644 --- a/backend/src/main/java/com/autoapplicant/port/in/company/ManageCompanyResearchUseCase.java +++ b/backend/src/main/java/com/autoapplicant/port/in/company/ManageCompanyResearchUseCase.java @@ -4,14 +4,17 @@ import java.util.Optional; import java.util.UUID; -/** Reads and writes the candidate's free-text research for a company (cover-letter grounding). */ +/** + * Reads and writes the candidate's free-text research for a company (cover-letter grounding). + * Notes belong to the user who wrote them; nobody else can read or change them. + */ public interface ManageCompanyResearchUseCase { - Optional getResearch(UUID companyId); + Optional getResearch(UUID userId, UUID companyId); /** * Saves the notes (blank clears them) and returns the resulting state, or empty when no such * company exists — so a write to a missing company is reported as a miss, not a false success. */ - Optional saveResearch(UUID companyId, String notes); + Optional saveResearch(UUID userId, UUID companyId, String notes); } diff --git a/backend/src/main/java/com/autoapplicant/port/out/company/CompanyRepositoryPort.java b/backend/src/main/java/com/autoapplicant/port/out/company/CompanyRepositoryPort.java index 35371d1b..e98d4066 100644 --- a/backend/src/main/java/com/autoapplicant/port/out/company/CompanyRepositoryPort.java +++ b/backend/src/main/java/com/autoapplicant/port/out/company/CompanyRepositoryPort.java @@ -2,7 +2,6 @@ import com.autoapplicant.domain.company.Company; import com.autoapplicant.domain.company.CompanyFacts; -import com.autoapplicant.domain.company.CompanyResearch; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -18,8 +17,4 @@ public interface CompanyRepositoryPort { // ── Cached company grounding facts ────────────────────── Optional findFacts(UUID companyId); void saveFacts(UUID companyId, String facts); - - // ── User-supplied company research ────────────────────── - Optional findResearch(UUID companyId); - void saveResearch(UUID companyId, String notes); } diff --git a/backend/src/main/java/com/autoapplicant/port/out/company/CompanyResearchRepositoryPort.java b/backend/src/main/java/com/autoapplicant/port/out/company/CompanyResearchRepositoryPort.java new file mode 100644 index 00000000..16c934c6 --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/port/out/company/CompanyResearchRepositoryPort.java @@ -0,0 +1,14 @@ +package com.autoapplicant.port.out.company; + +import com.autoapplicant.domain.company.CompanyResearch; +import java.util.Optional; +import java.util.UUID; + +/** A user's own research notes on a company. Each user sees and edits only their own. */ +public interface CompanyResearchRepositoryPort { + + Optional findResearch(UUID userId, UUID companyId); + + /** Stores the notes; blank notes delete them. */ + void saveResearch(UUID userId, UUID companyId, String notes); +} diff --git a/backend/src/main/java/com/autoapplicant/usecase/ai/ApplicationPromptAssembler.java b/backend/src/main/java/com/autoapplicant/usecase/ai/ApplicationPromptAssembler.java index 87adcaef..d9c04c3d 100644 --- a/backend/src/main/java/com/autoapplicant/usecase/ai/ApplicationPromptAssembler.java +++ b/backend/src/main/java/com/autoapplicant/usecase/ai/ApplicationPromptAssembler.java @@ -57,7 +57,7 @@ public GenerationInputs assemble(GenerateDocumentCommand cmd) { // research) so company references in cover letters are accurate rather than parroted from // the untrusted posting. CompanyContext companyContext = job != null - ? companyGrounding.contextFor(job.companyId()) : CompanyContext.EMPTY; + ? companyGrounding.contextFor(cmd.userId(), job.companyId()) : CompanyContext.EMPTY; PromptComposition composition = compositionBuilder.composeStructuredApplicationPrompt( cmd.documentType(), contactFreeJson, postingFor(job, jobDescription), diff --git a/backend/src/main/java/com/autoapplicant/usecase/ai/CompanyGroundingService.java b/backend/src/main/java/com/autoapplicant/usecase/ai/CompanyGroundingService.java index 2b5f6e9d..3fb95085 100644 --- a/backend/src/main/java/com/autoapplicant/usecase/ai/CompanyGroundingService.java +++ b/backend/src/main/java/com/autoapplicant/usecase/ai/CompanyGroundingService.java @@ -7,6 +7,7 @@ import com.autoapplicant.domain.document.PromptComposition; import com.autoapplicant.port.out.ai.ChatProviderPort; import com.autoapplicant.port.out.company.CompanyRepositoryPort; +import com.autoapplicant.port.out.company.CompanyResearchRepositoryPort; import com.autoapplicant.port.out.web.WebPageFetchPort; import java.time.Duration; import java.time.Instant; @@ -31,6 +32,7 @@ public class CompanyGroundingService { private static final Logger log = LoggerFactory.getLogger(CompanyGroundingService.class); private final CompanyRepositoryPort companyRepo; + private final CompanyResearchRepositoryPort researchRepo; private final WebPageFetchPort webFetch; private final ChatProviderPort ai; @@ -41,23 +43,26 @@ public class CompanyGroundingService { private int stalenessDays; public CompanyGroundingService(CompanyRepositoryPort companyRepo, + CompanyResearchRepositoryPort researchRepo, WebPageFetchPort webFetch, @Qualifier("enrichmentAiProvider") ChatProviderPort ai) { this.companyRepo = companyRepo; + this.researchRepo = researchRepo; this.webFetch = webFetch; this.ai = ai; } - /** The verified facts and the candidate's research for a company, for prompt grounding. */ - public CompanyContext contextFor(UUID companyId) { + /** The verified facts and this user's own research on a company, for prompt grounding. */ + public CompanyContext contextFor(UUID userId, UUID companyId) { if (companyId == null) return CompanyContext.EMPTY; - return new CompanyContext(factsFor(companyId), researchNotesFor(companyId)); + return new CompanyContext(factsFor(companyId), researchNotesFor(userId, companyId)); } - /** The candidate's saved research notes, or null when none/unavailable. Never throws. */ - private String researchNotesFor(UUID companyId) { + /** The user's saved research notes, or null when none/unavailable. Never throws. */ + private String researchNotesFor(UUID userId, UUID companyId) { try { - return companyRepo.findResearch(companyId).map(CompanyResearch::notes).orElse(null); + return researchRepo.findResearch(userId, companyId) + .map(CompanyResearch::notes).orElse(null); } catch (Exception e) { log.warn("Reading company research failed for {}: {}", companyId, e.getMessage()); return null; diff --git a/backend/src/main/java/com/autoapplicant/usecase/company/CompanyService.java b/backend/src/main/java/com/autoapplicant/usecase/company/CompanyService.java index d4b5c575..80b34a9e 100644 --- a/backend/src/main/java/com/autoapplicant/usecase/company/CompanyService.java +++ b/backend/src/main/java/com/autoapplicant/usecase/company/CompanyService.java @@ -5,6 +5,7 @@ import com.autoapplicant.port.in.company.GetCompaniesUseCase; import com.autoapplicant.port.in.company.ManageCompanyResearchUseCase; import com.autoapplicant.port.out.company.CompanyRepositoryPort; +import com.autoapplicant.port.out.company.CompanyResearchRepositoryPort; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -14,9 +15,11 @@ public class CompanyService implements GetCompaniesUseCase, ManageCompanyResearchUseCase { private final CompanyRepositoryPort repo; + private final CompanyResearchRepositoryPort researchRepo; - public CompanyService(CompanyRepositoryPort repo) { + public CompanyService(CompanyRepositoryPort repo, CompanyResearchRepositoryPort researchRepo) { this.repo = repo; + this.researchRepo = researchRepo; } @Override @@ -30,18 +33,19 @@ public Optional getCompanyById(UUID id) { } @Override - public Optional getResearch(UUID companyId) { - return repo.findResearch(companyId); + public Optional getResearch(UUID userId, UUID companyId) { + return researchRepo.findResearch(userId, companyId); } @Override - public Optional saveResearch(UUID companyId, String notes) { + public Optional saveResearch(UUID userId, UUID companyId, String notes) { if (repo.findById(companyId).isEmpty()) { return Optional.empty(); } - repo.saveResearch(companyId, notes); + researchRepo.saveResearch(userId, companyId, notes); // Read back so the caller gets the persisted timestamp; blank notes clear the field, which // findResearch reports as absent — a cleared note has nothing to show. - return Optional.of(repo.findResearch(companyId).orElse(new CompanyResearch(null, null))); + return Optional.of(researchRepo.findResearch(userId, companyId) + .orElse(new CompanyResearch(null, null))); } } diff --git a/backend/src/main/resources/db/migration/V036__user_company_notes.sql b/backend/src/main/resources/db/migration/V036__user_company_notes.sql new file mode 100644 index 00000000..9b632578 --- /dev/null +++ b/backend/src/main/resources/db/migration/V036__user_company_notes.sql @@ -0,0 +1,44 @@ +-- Company research notes become per user. +-- +-- V033 put the notes on the shared companies row, so every user read and overwrote the same text +-- (and it grounded everyone's cover letters). They now live in their own table keyed by +-- (user_id, company_id). + +CREATE TABLE user_company_notes ( + user_id uuid NOT NULL, + company_id uuid NOT NULL, + notes text NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_company_notes_pkey PRIMARY KEY (user_id, company_id), + CONSTRAINT user_company_notes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT user_company_notes_company_id_fkey FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE +); +CREATE INDEX idx_user_company_notes_company ON public.user_company_notes USING btree (company_id); + +-- Existing notes have no author. Hand a note to a user only when exactly one user has an application +-- or a tracked outreach for that company; anything else is ambiguous and is dropped, because copying +-- it to several users would keep the leak this migration exists to close. +WITH candidates AS ( + SELECT a.user_id, j.company_id + FROM applications a + JOIN jobs j ON j.id = a.job_id + WHERE j.company_id IS NOT NULL + UNION + SELECT o.user_id, o.company_id + FROM outreach_contact o + WHERE o.company_id IS NOT NULL +), +sole_owner AS ( + SELECT company_id, min(user_id::text)::uuid AS user_id + FROM candidates + GROUP BY company_id + HAVING count(DISTINCT user_id) = 1 +) +INSERT INTO user_company_notes (user_id, company_id, notes, updated_at) +SELECT s.user_id, c.id, c.research_notes, coalesce(c.research_notes_updated_at, now()) +FROM companies c +JOIN sole_owner s ON s.company_id = c.id +WHERE c.research_notes IS NOT NULL AND btrim(c.research_notes) <> ''; + +ALTER TABLE companies DROP COLUMN research_notes; +ALTER TABLE companies DROP COLUMN research_notes_updated_at; diff --git a/backend/src/test/java/com/autoapplicant/adapter/persistence/PostgresIntegrationTest.java b/backend/src/test/java/com/autoapplicant/adapter/persistence/PostgresIntegrationTest.java index 96355d6b..f08a7eb1 100644 --- a/backend/src/test/java/com/autoapplicant/adapter/persistence/PostgresIntegrationTest.java +++ b/backend/src/test/java/com/autoapplicant/adapter/persistence/PostgresIntegrationTest.java @@ -3,8 +3,8 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; @@ -12,6 +12,10 @@ * Base for persistence tests against a real Postgres with pgvector (the image * docker-compose runs), migrated by Flyway exactly as production is. Skipped, not * failed, where no Docker daemon is reachable; CI always has one. + * + *

One container for the whole run rather than one per class: Spring caches the + * context across classes with the same config, and a per-class container would leave + * that cached context pointing at a stopped database. */ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -21,7 +25,12 @@ public abstract class PostgresIntegrationTest { private static final DockerImageName PGVECTOR = DockerImageName.parse("pgvector/pgvector:pg16").asCompatibleSubstituteFor("postgres"); - @Container @ServiceConnection static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>(PGVECTOR); + + static { + if (DockerClientFactory.instance().isDockerAvailable()) { + POSTGRES.start(); + } + } } diff --git a/backend/src/test/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapterTest.java b/backend/src/test/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapterTest.java new file mode 100644 index 00000000..a87f9ede --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapterTest.java @@ -0,0 +1,82 @@ +package com.autoapplicant.adapter.persistence.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.autoapplicant.adapter.persistence.PostgresIntegrationTest; +import com.autoapplicant.domain.company.CompanyResearch; +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.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +@Import(CompanyResearchPersistenceAdapter.class) +class CompanyResearchPersistenceAdapterTest extends PostgresIntegrationTest { + + @Autowired CompanyResearchPersistenceAdapter research; + @Autowired JdbcTemplate jdbc; + + UUID alice; + UUID bob; + UUID company; + + @BeforeEach + void setUp() { + alice = insertUser(); + bob = insertUser(); + company = UUID.randomUUID(); + jdbc.update("INSERT INTO companies (id, name) VALUES (?, 'Acme ApS')", company); + } + + @Test + void two_users_keep_their_own_notes_on_the_same_company() { + research.saveResearch(alice, company, "Alice's notes"); + research.saveResearch(bob, company, "Bob's notes"); + + assertThat(research.findResearch(alice, company)).map(CompanyResearch::notes).contains("Alice's notes"); + assertThat(research.findResearch(bob, company)).map(CompanyResearch::notes).contains("Bob's notes"); + assertThat(rowCount()).isEqualTo(2); + } + + @Test + void saving_again_updates_in_place_and_stamps_the_time() { + research.saveResearch(alice, company, "first draft"); + research.saveResearch(alice, company, "second draft"); + + assertThat(research.findResearch(alice, company)).hasValueSatisfying(r -> { + assertThat(r.notes()).isEqualTo("second draft"); + assertThat(r.updatedAt()).isNotNull(); + }); + assertThat(rowCount()).isEqualTo(1); + } + + @Test + void blank_notes_delete_only_that_users_row() { + research.saveResearch(alice, company, "Alice's notes"); + research.saveResearch(bob, company, "Bob's notes"); + + research.saveResearch(bob, company, " "); + + assertThat(research.findResearch(bob, company)).isEmpty(); + assertThat(research.findResearch(alice, company)).isPresent(); + } + + @Test + void clearing_notes_that_were_never_saved_is_a_no_op() { + research.saveResearch(alice, company, null); + + assertThat(research.findResearch(alice, company)).isEmpty(); + } + + private UUID insertUser() { + UUID id = UUID.randomUUID(); + jdbc.update("INSERT INTO users (id, email) VALUES (?, ?)", id, id + "@example.com"); + return id; + } + + private int rowCount() { + return jdbc.queryForObject( + "SELECT count(*) FROM user_company_notes WHERE company_id = ?", Integer.class, company); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/persistence/migration/UserCompanyNotesMigrationTest.java b/backend/src/test/java/com/autoapplicant/adapter/persistence/migration/UserCompanyNotesMigrationTest.java new file mode 100644 index 00000000..ea6007a0 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/persistence/migration/UserCompanyNotesMigrationTest.java @@ -0,0 +1,106 @@ +package com.autoapplicant.adapter.persistence.migration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.autoapplicant.adapter.persistence.PostgresIntegrationTest; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +/** + * V036 moves the shared company notes onto (user, company) rows. Migrates a scratch database up to + * V035, seeds pre-V036 data, then applies the rest and checks where each note landed. + */ +class UserCompanyNotesMigrationTest extends PostgresIntegrationTest { + + private static final String BEFORE_PER_USER_NOTES = "35"; + + @Autowired JdbcConnectionDetails db; + + DriverManagerDataSource scratch; + JdbcTemplate jdbc; + + @BeforeEach + void migrateScratchDatabaseToV035() throws SQLException { + String name = "v036_" + UUID.randomUUID().toString().replace("-", ""); + try (Connection c = DriverManager.getConnection(db.getJdbcUrl(), db.getUsername(), db.getPassword()); + Statement s = c.createStatement()) { + s.execute("CREATE DATABASE " + name); + } + String url = db.getJdbcUrl().replaceFirst("/[^/?]+(\\?|$)", "/" + name + "$1"); + scratch = new DriverManagerDataSource(url, db.getUsername(), db.getPassword()); + jdbc = new JdbcTemplate(scratch); + flyway().target(BEFORE_PER_USER_NOTES).load().migrate(); + } + + @Test + void a_note_goes_to_the_one_user_working_that_company_and_ambiguous_ones_are_dropped() { + UUID alice = user(); + UUID bob = user(); + UUID applied = company("Applied to only by Alice", "Alice's research"); + UUID outreach = company("Outreach only by Bob", "Bob's research"); + UUID shared = company("Both of them", "Whose research?"); + UUID nobody = company("Nobody", "Orphaned research"); + UUID blank = company("Blank", " "); + application(alice, applied); + application(alice, applied); + outreach(bob, outreach); + application(alice, shared); + outreach(bob, shared); + application(alice, blank); + + flyway().load().migrate(); + + List> rows = jdbc.queryForList( + "SELECT user_id, company_id, notes FROM user_company_notes ORDER BY notes"); + assertThat(rows).containsExactly( + Map.of("user_id", alice, "company_id", applied, "notes", "Alice's research"), + Map.of("user_id", bob, "company_id", outreach, "notes", "Bob's research")); + assertThat(rows).noneMatch(r -> r.get("company_id").equals(nobody)); + assertThat(jdbc.queryForObject("SELECT count(*) FROM information_schema.columns" + + " WHERE table_name = 'companies' AND column_name LIKE 'research_notes%'", Integer.class)) + .isZero(); + } + + private FluentConfiguration flyway() { + return Flyway.configure().dataSource(scratch).locations("classpath:db/migration"); + } + + private UUID user() { + UUID id = UUID.randomUUID(); + jdbc.update("INSERT INTO users (id, email) VALUES (?, ?)", id, id + "@example.com"); + return id; + } + + private UUID company(String name, String notes) { + UUID id = UUID.randomUUID(); + jdbc.update("INSERT INTO companies (id, name, research_notes, research_notes_updated_at)" + + " VALUES (?, ?, ?, now())", id, name, notes); + return id; + } + + private void application(UUID userId, UUID companyId) { + UUID job = UUID.randomUUID(); + jdbc.update("INSERT INTO jobs (id, source, url, title, company_name, company_id)" + + " VALUES (?, 'JOBINDEX', ?, 'Developer', 'Acme ApS', ?)", + job, "https://example.com/jobs/" + job, companyId); + jdbc.update("INSERT INTO applications (user_id, job_id) VALUES (?, ?)", userId, job); + } + + private void outreach(UUID userId, UUID companyId) { + jdbc.update("INSERT INTO outreach_contact (user_id, company_id, company_name)" + + " VALUES (?, ?, 'Acme ApS')", userId, companyId); + } +} diff --git a/backend/src/test/java/com/autoapplicant/usecase/ai/ApplicationPromptAssemblerTest.java b/backend/src/test/java/com/autoapplicant/usecase/ai/ApplicationPromptAssemblerTest.java index 31d5e402..ab44e150 100644 --- a/backend/src/test/java/com/autoapplicant/usecase/ai/ApplicationPromptAssemblerTest.java +++ b/backend/src/test/java/com/autoapplicant/usecase/ai/ApplicationPromptAssemblerTest.java @@ -9,6 +9,8 @@ import static org.mockito.Mockito.when; import com.autoapplicant.domain.ai.GenerateDocumentCommand; +import com.autoapplicant.domain.document.CompanyContext; +import com.autoapplicant.domain.job.Job; import com.autoapplicant.port.out.application.ApplicationRepositoryPort; import com.autoapplicant.port.out.document.PromptTemplateRepositoryPort; import com.autoapplicant.port.out.document.WritingProfileRepositoryPort; @@ -79,6 +81,26 @@ void withNoJobItNeitherLooksUpNorGroundsACompany() { null, null, "English", false, null, "STANDARD")); verify(jobRepo, never()).findById(any()); - verify(companyGrounding, never()).contextFor(any()); + verify(companyGrounding, never()).contextFor(any(), any()); + } + + @Test + void groundsTheCompanyWithTheRequestingUsersOwnResearch() { + UUID jobId = UUID.randomUUID(); + UUID companyId = UUID.randomUUID(); + Job job = mock(Job.class); + when(job.companyId()).thenReturn(companyId); + when(jobRepo.findById(jobId)).thenReturn(Optional.of(job)); + when(companyGrounding.contextFor(any(), any())).thenReturn(CompanyContext.EMPTY); + when(careerProfileContext.buildJson(any())).thenReturn(PROFILE_JSON); + when(promptTemplateRepo.findDefaultFor(any(), any())).thenReturn(Optional.empty()); + when(writingProfileRepo.findByUserId(any())).thenReturn(Optional.empty()); + when(applicationRepo.findRecentOutcomeLessons(any(), anyInt())).thenReturn(List.of()); + + assembler.assemble(new GenerateDocumentCommand( + USER, "COVER_LETTER", jobId, null, null, null, + null, null, "English", false, null, "STANDARD")); + + verify(companyGrounding).contextFor(USER, companyId); } } diff --git a/backend/src/test/java/com/autoapplicant/usecase/ai/CompanyGroundingServiceTest.java b/backend/src/test/java/com/autoapplicant/usecase/ai/CompanyGroundingServiceTest.java new file mode 100644 index 00000000..e9210751 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/usecase/ai/CompanyGroundingServiceTest.java @@ -0,0 +1,40 @@ +package com.autoapplicant.usecase.ai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.autoapplicant.domain.document.CompanyContext; +import com.autoapplicant.port.out.ai.ChatProviderPort; +import com.autoapplicant.port.out.company.CompanyRepositoryPort; +import com.autoapplicant.port.out.web.WebPageFetchPort; +import com.autoapplicant.usecase.company.InMemoryCompanyResearchRepository; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CompanyGroundingServiceTest { + + private static final UUID COMPANY = UUID.randomUUID(); + private static final UUID ALICE = UUID.randomUUID(); + private static final UUID BOB = UUID.randomUUID(); + + private final InMemoryCompanyResearchRepository research = new InMemoryCompanyResearchRepository(); + private final CompanyGroundingService grounding = new CompanyGroundingService( + mock(CompanyRepositoryPort.class), research, + mock(WebPageFetchPort.class), mock(ChatProviderPort.class)); + + @Test + void groundsACoverLetterOnlyInTheRequestingUsersOwnNotes() { + research.saveResearch(ALICE, COMPANY, "Alice's research"); + + CompanyContext forAlice = grounding.contextFor(ALICE, COMPANY); + CompanyContext forBob = grounding.contextFor(BOB, COMPANY); + + assertThat(forAlice.researchNotes()).isEqualTo("Alice's research"); + assertThat(forBob.researchNotes()).isNull(); + } + + @Test + void noCompanyMeansNoContext() { + assertThat(grounding.contextFor(ALICE, null)).isEqualTo(CompanyContext.EMPTY); + } +} diff --git a/backend/src/test/java/com/autoapplicant/usecase/company/CompanyServiceTest.java b/backend/src/test/java/com/autoapplicant/usecase/company/CompanyServiceTest.java index 0753a07d..30b2eca4 100644 --- a/backend/src/test/java/com/autoapplicant/usecase/company/CompanyServiceTest.java +++ b/backend/src/test/java/com/autoapplicant/usecase/company/CompanyServiceTest.java @@ -1,48 +1,79 @@ package com.autoapplicant.usecase.company; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.autoapplicant.domain.company.Company; import com.autoapplicant.domain.company.CompanyResearch; import com.autoapplicant.port.out.company.CompanyRepositoryPort; -import java.time.Instant; import java.util.Optional; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class CompanyServiceTest { private static final UUID COMPANY = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-000000000002"); private final CompanyRepositoryPort repo = mock(CompanyRepositoryPort.class); - private final CompanyService service = new CompanyService(repo); + private final InMemoryCompanyResearchRepository research = new InMemoryCompanyResearchRepository(); + private final CompanyService service = new CompanyService(repo, research); + + @BeforeEach + void companyExists() { + when(repo.findById(COMPANY)).thenReturn(Optional.of(mock(Company.class))); + } @Test void savingResearchForAnUnknownCompanyIsAMissAndWritesNothing() { - when(repo.findById(COMPANY)).thenReturn(Optional.empty()); + UUID unknown = UUID.randomUUID(); + when(repo.findById(unknown)).thenReturn(Optional.empty()); - Optional result = service.saveResearch(COMPANY, "some notes"); + Optional result = service.saveResearch(ALICE, unknown, "some notes"); // A write to a company that does not exist must not report success (it maps to a 404). assertThat(result).isEmpty(); - verify(repo, never()).saveResearch(any(), any()); + assertThat(research.findResearch(ALICE, unknown)).isEmpty(); } @Test - void savingResearchForAKnownCompanyPersistsThenReturnsThePersistedState() { - Instant savedAt = Instant.parse("2026-01-01T00:00:00Z"); - when(repo.findById(COMPANY)).thenReturn(Optional.of(mock(Company.class))); - when(repo.findResearch(COMPANY)).thenReturn(Optional.of(new CompanyResearch("notes", savedAt))); + void savingResearchReturnsThePersistedState() { + Optional result = service.saveResearch(ALICE, COMPANY, "notes"); + + assertThat(result).hasValueSatisfying(r -> { + assertThat(r.notes()).isEqualTo("notes"); + assertThat(r.updatedAt()).isNotNull(); + }); + } + + @Test + void twoUsersKeepSeparateNotesOnTheSameCompany() { + service.saveResearch(ALICE, COMPANY, "Alice's take"); + service.saveResearch(BOB, COMPANY, "Bob's take"); - Optional result = service.saveResearch(COMPANY, "notes"); + assertThat(service.getResearch(ALICE, COMPANY)).map(CompanyResearch::notes).contains("Alice's take"); + assertThat(service.getResearch(BOB, COMPANY)).map(CompanyResearch::notes).contains("Bob's take"); + } + + @Test + void oneUserClearingTheirNotesLeavesTheOtherUsersAlone() { + service.saveResearch(ALICE, COMPANY, "Alice's take"); + service.saveResearch(BOB, COMPANY, "Bob's take"); + + Optional cleared = service.saveResearch(BOB, COMPANY, " "); + + assertThat(cleared).contains(new CompanyResearch(null, null)); + assertThat(service.getResearch(BOB, COMPANY)).isEmpty(); + assertThat(service.getResearch(ALICE, COMPANY)).map(CompanyResearch::notes).contains("Alice's take"); + } + + @Test + void aUserWithoutNotesSeesNothingEvenWhenSomeoneElseHasSome() { + service.saveResearch(ALICE, COMPANY, "Alice's take"); - verify(repo).saveResearch(eq(COMPANY), eq("notes")); - assertThat(result).contains(new CompanyResearch("notes", savedAt)); + assertThat(service.getResearch(BOB, COMPANY)).isEmpty(); } } diff --git a/backend/src/test/java/com/autoapplicant/usecase/company/InMemoryCompanyResearchRepository.java b/backend/src/test/java/com/autoapplicant/usecase/company/InMemoryCompanyResearchRepository.java new file mode 100644 index 00000000..ead8309e --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/usecase/company/InMemoryCompanyResearchRepository.java @@ -0,0 +1,30 @@ +package com.autoapplicant.usecase.company; + +import com.autoapplicant.domain.company.CompanyResearch; +import com.autoapplicant.port.out.company.CompanyResearchRepositoryPort; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** Research notes keyed by (user, company), the same key the real table uses. */ +public class InMemoryCompanyResearchRepository implements CompanyResearchRepositoryPort { + + private final Map, CompanyResearch> rows = new HashMap<>(); + + @Override + public Optional findResearch(UUID userId, UUID companyId) { + return Optional.ofNullable(rows.get(List.of(userId, companyId))); + } + + @Override + public void saveResearch(UUID userId, UUID companyId, String notes) { + if (notes == null || notes.isBlank()) { + rows.remove(List.of(userId, companyId)); + } else { + rows.put(List.of(userId, companyId), new CompanyResearch(notes, Instant.now())); + } + } +} From cc0fa47b3d43b16bf1f1fdf27ca3be50fa99208a Mon Sep 17 00:00:00 2001 From: CMaintz Date: Thu, 1 Oct 2026 10:49:46 +0200 Subject: [PATCH 2/2] refactor(backend): simpler key and save path for user company notes --- .../CompanyResearchPersistenceAdapter.java | 7 +--- .../entity/UserCompanyNoteEntity.java | 36 +++++-------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java index 6f8fcdfc..dca8846e 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java @@ -32,12 +32,7 @@ public void saveResearch(UUID userId, UUID companyId, String notes) { repo.deleteById(key); return; } - UserCompanyNoteEntity entity = repo.findById(key).orElseGet(() -> { - UserCompanyNoteEntity created = new UserCompanyNoteEntity(); - created.setUserId(userId); - created.setCompanyId(companyId); - return created; - }); + UserCompanyNoteEntity entity = repo.findById(key).orElseGet(() -> new UserCompanyNoteEntity(key)); entity.setNotes(notes); repo.saveAndFlush(entity); } diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java index 914e010b..9076c68e 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java @@ -3,22 +3,15 @@ import jakarta.persistence.*; import java.io.Serializable; import java.time.Instant; -import java.util.Objects; import java.util.UUID; /** One user's research notes on one company. */ @Entity @Table(name = "user_company_notes") -@IdClass(UserCompanyNoteEntity.Key.class) public class UserCompanyNoteEntity { - @Id - @Column(name = "user_id", nullable = false) - private UUID userId; - - @Id - @Column(name = "company_id", nullable = false) - private UUID companyId; + @EmbeddedId + private Key id; @Column(nullable = false, columnDefinition = "text") private String notes; @@ -26,29 +19,18 @@ public class UserCompanyNoteEntity { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + protected UserCompanyNoteEntity() {} + + public UserCompanyNoteEntity(Key id) { this.id = id; } + @PrePersist @PreUpdate void touch() { updatedAt = Instant.now(); } - public UUID getUserId() { return userId; } - public void setUserId(UUID userId) { this.userId = userId; } - public UUID getCompanyId() { return companyId; } - public void setCompanyId(UUID companyId) { this.companyId = companyId; } public String getNotes() { return notes; } public void setNotes(String notes) { this.notes = notes; } public Instant getUpdatedAt() { return updatedAt; } - public static class Key implements Serializable { - private UUID userId; - private UUID companyId; - - public Key() {} - public Key(UUID userId, UUID companyId) { this.userId = userId; this.companyId = companyId; } - - @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Key key)) return false; - return Objects.equals(userId, key.userId) && Objects.equals(companyId, key.companyId); - } - @Override public int hashCode() { return Objects.hash(userId, companyId); } - } + @Embeddable + public record Key(@Column(name = "user_id") UUID userId, + @Column(name = "company_id") UUID companyId) implements Serializable {} }