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..dca8846e --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/CompanyResearchPersistenceAdapter.java @@ -0,0 +1,39 @@ +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(() -> new UserCompanyNoteEntity(key)); + 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..9076c68e --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/entity/UserCompanyNoteEntity.java @@ -0,0 +1,36 @@ +package com.autoapplicant.adapter.persistence.entity; + +import jakarta.persistence.*; +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +/** One user's research notes on one company. */ +@Entity +@Table(name = "user_company_notes") +public class UserCompanyNoteEntity { + + @EmbeddedId + private Key id; + + @Column(nullable = false, columnDefinition = "text") + private String notes; + + @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 String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + public Instant getUpdatedAt() { return updatedAt; } + + @Embeddable + public record Key(@Column(name = "user_id") UUID userId, + @Column(name = "company_id") UUID companyId) implements Serializable {} +} 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())); + } + } +}