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
Expand Up @@ -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;
Expand Down Expand Up @@ -81,23 +80,6 @@ public void saveFacts(UUID companyId, String facts) {
});
}

@Override
public Optional<CompanyResearch> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CompanyResearch> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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 {}
}
Original file line number Diff line number Diff line change
@@ -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<UserCompanyNoteEntity, UserCompanyNoteEntity.Key> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,22 @@ public ResponseEntity<Company> 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<CompanyResearch> 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<CompanyResearch> 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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompanyResearch> getResearch(UUID companyId);
Optional<CompanyResearch> 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<CompanyResearch> saveResearch(UUID companyId, String notes);
Optional<CompanyResearch> saveResearch(UUID userId, UUID companyId, String notes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,8 +17,4 @@ public interface CompanyRepositoryPort {
// ── Cached company grounding facts ──────────────────────
Optional<CompanyFacts> findFacts(UUID companyId);
void saveFacts(UUID companyId, String facts);

// ── User-supplied company research ──────────────────────
Optional<CompanyResearch> findResearch(UUID companyId);
void saveResearch(UUID companyId, String notes);
}
Original file line number Diff line number Diff line change
@@ -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<CompanyResearch> findResearch(UUID userId, UUID companyId);

/** Stores the notes; blank notes delete them. */
void saveResearch(UUID userId, UUID companyId, String notes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -30,18 +33,19 @@ public Optional<Company> getCompanyById(UUID id) {
}

@Override
public Optional<CompanyResearch> getResearch(UUID companyId) {
return repo.findResearch(companyId);
public Optional<CompanyResearch> getResearch(UUID userId, UUID companyId) {
return researchRepo.findResearch(userId, companyId);
}

@Override
public Optional<CompanyResearch> saveResearch(UUID companyId, String notes) {
public Optional<CompanyResearch> 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)));
}
}
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading