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
@@ -1,5 +1,6 @@
package com.jobpilot.candidate.config;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.jobpilot.candidate.domain.CandidateLanguageLevel;
import com.jobpilot.candidate.domain.CandidateSkillCategory;
import com.jobpilot.candidate.domain.ProjectType;
Expand All @@ -23,6 +24,12 @@
@Validated
@ConfigurationProperties("jobpilot.candidate-profile")
public record CandidateProfileProperties(
// Stable key of the persistent Candidate that owns this profile. Ownership is not a
// candidate fact, so @JsonIgnore keeps it out of the source-hash fingerprint below:
// re-keying the owner must not read as "facts changed without increasing profileVersion",
// and the fingerprint of an already stored version must stay exactly what it was.
@JsonIgnore
@NotBlank @Size(max = 100) @Pattern(regexp = "[a-z0-9]+(?:-[a-z0-9]+)*") String candidateKey,
@Positive int profileVersion,
@NotBlank @Size(max = 200) String fullName,
@NotBlank @Size(max = 300) String location,
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/com/jobpilot/candidate/domain/Candidate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.jobpilot.candidate.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;

/**
* The owner of one or more {@link CandidateProfile} versions.
*
* <p>Deliberately minimal: it establishes ownership and nothing else. Identity, credentials,
* contact details and preferences belong to a later phase, and none of them are needed to say
* which candidate a stored profile version describes.
*
* <p>{@code stableKey} is the only identity that survives a database rebuild, so configuration
* and the seeded row are matched on it rather than on the generated id.
*
* <p>Ownership is modelled one-directionally from the profile. Nothing today needs to walk from a
* candidate to its profile versions, and a mapped collection would only invite loading them all.
*/
@Entity
@Table(name = "candidates")
public class Candidate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String stableKey;
@Column(nullable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;

protected Candidate() {
}

public Candidate(String stableKey, Instant now) {
if (stableKey == null || stableKey.isBlank()) {
throw new IllegalArgumentException("Candidate stable key is required");
}
this.stableKey = stableKey.trim();
this.createdAt = now;
this.updatedAt = now;
}

public Long getId() { return id; }
public String getStableKey() { return stableKey; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
Expand All @@ -15,13 +17,21 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

@Entity
@Table(name = "candidate_profiles")
public class CandidateProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "candidate_id", nullable = false)
private Candidate candidate;
/**
* Still globally unique in the schema, not unique per candidate. Making version and active
* uniqueness candidate-scoped is a separate migration.
*/
@Column(nullable = false, unique = true)
private int profileVersion;
@Column(nullable = false, length = 200)
Expand Down Expand Up @@ -67,11 +77,12 @@ public class CandidateProfile {
protected CandidateProfile() {
}

public CandidateProfile(int profileVersion, String fullName, String location,
public CandidateProfile(Candidate candidate, int profileVersion, String fullName, String location,
String educationInstitution, String degree, int studyStartYear,
Integer studyEndYear, boolean currentStudent, boolean finalYearStudent,
BigDecimal commercialJavaExperienceYears, String sourceHash,
Instant now, boolean active) {
this.candidate = Objects.requireNonNull(candidate, "Owning candidate is required");
this.profileVersion = profileVersion;
this.fullName = fullName;
this.location = location;
Expand Down Expand Up @@ -118,6 +129,7 @@ public void deactivate(Instant now) {
}

public Long getId() { return id; }
public Candidate getCandidate() { return candidate; }
public int getProfileVersion() { return profileVersion; }
public String getFullName() { return fullName; }
public String getLocation() { return location; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface CandidateProfileRepository extends JpaRepository<CandidateProfile, Long> {
Optional<CandidateProfile> findByCandidateIdAndActiveTrue(Long candidateId);

Optional<CandidateProfile> findByCandidateIdAndProfileVersion(Long candidateId, int profileVersion);

/**
* Installation-wide lookups, meaning "the one active profile in the whole system". Still
* correct only because a single candidate exists; {@code JobAnalysisService} and
* {@code ResumeGenerationService} read the active profile this way and are migrated to
* candidate-scoped access in a later phase.
*/
Optional<CandidateProfile> findByActiveTrue();

Optional<CandidateProfile> findByProfileVersion(int profileVersion);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.jobpilot.candidate.repository;

import com.jobpilot.candidate.domain.Candidate;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CandidateRepository extends JpaRepository<Candidate, Long> {
Optional<Candidate> findByStableKey(String stableKey);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jobpilot.candidate.config.CandidateProfileProperties;
import com.jobpilot.candidate.domain.Candidate;
import com.jobpilot.candidate.domain.CandidateProfile;
import com.jobpilot.candidate.domain.CandidateProject;
import com.jobpilot.candidate.repository.CandidateProfileRepository;
import com.jobpilot.candidate.repository.CandidateRepository;
import com.jobpilot.common.Hashing;
import java.time.Clock;
import java.time.Instant;
Expand All @@ -19,14 +21,17 @@ public class CandidateProfileBootstrapService {
private static final Logger log = LoggerFactory.getLogger(CandidateProfileBootstrapService.class);

private final CandidateProfileRepository profiles;
private final CandidateRepository candidates;
private final CandidateProfileDefinitionValidator validator;
private final ObjectMapper objectMapper;
private final Clock clock;

public CandidateProfileBootstrapService(CandidateProfileRepository profiles,
CandidateRepository candidates,
CandidateProfileDefinitionValidator validator,
ObjectMapper objectMapper, Clock clock) {
this.profiles = profiles;
this.candidates = candidates;
this.validator = validator;
this.objectMapper = objectMapper;
this.clock = clock;
Expand All @@ -36,7 +41,8 @@ public CandidateProfileBootstrapService(CandidateProfileRepository profiles,
public CandidateProfileBootstrapResult bootstrap(CandidateProfileProperties definition) {
validator.validate(definition);
String sourceHash = sourceHash(definition);
var active = profiles.findByActiveTrue();
Candidate candidate = resolveCandidate(definition.candidateKey());
var active = profiles.findByCandidateIdAndActiveTrue(candidate.getId());
if (active.isPresent()) {
CandidateProfile current = active.get();
if (definition.profileVersion() < current.getProfileVersion()) {
Expand All @@ -56,26 +62,39 @@ public CandidateProfileBootstrapResult bootstrap(CandidateProfileProperties defi
}
}

profiles.findByProfileVersion(definition.profileVersion()).ifPresent(existing -> {
throw new CandidateProfileVersionConflictException(
"Configured profileVersion already exists and cannot be overwritten");
});
profiles.findByCandidateIdAndProfileVersion(candidate.getId(), definition.profileVersion())
.ifPresent(existing -> {
throw new CandidateProfileVersionConflictException(
"Configured profileVersion already exists and cannot be overwritten");
});

Instant now = clock.instant();
active.ifPresent(current -> {
current.deactivate(now);
profiles.saveAndFlush(current);
});
CandidateProfile created = map(definition, sourceHash, now);
CandidateProfile created = map(candidate, definition, sourceHash, now);
profiles.saveAndFlush(created);
log.info("Candidate profile bootstrap created: version={}, skills={}, languages={}, projects={}",
created.getProfileVersion(), created.getSkills().size(),
log.info("Candidate profile bootstrap created: candidate={}, version={}, skills={}, languages={}, projects={}",
candidate.getStableKey(), created.getProfileVersion(), created.getSkills().size(),
created.getLanguages().size(), created.getProjects().size());
return new CandidateProfileBootstrapResult(created.getId(), created.getProfileVersion(), true);
}

private CandidateProfile map(CandidateProfileProperties definition, String sourceHash, Instant now) {
CandidateProfile profile = new CandidateProfile(
/**
* V14 seeds the configured key, so both an upgraded and a freshly migrated database already
* hold this row and the lookup is what normally runs. Creating a missing one keeps a re-keyed
* configuration deterministic instead of failing at startup; the unique stable key makes
* repeated bootstrap resolve the same row rather than adding another owner.
*/
private Candidate resolveCandidate(String stableKey) {
return candidates.findByStableKey(stableKey)
.orElseGet(() -> candidates.saveAndFlush(new Candidate(stableKey, clock.instant())));
}

private CandidateProfile map(Candidate candidate, CandidateProfileProperties definition,
String sourceHash, Instant now) {
CandidateProfile profile = new CandidateProfile(candidate,
definition.profileVersion(), definition.fullName().trim(), definition.location().trim(),
definition.educationInstitution().trim(), definition.degree().trim(),
definition.studyStartYear(), definition.studyEndYear(), definition.currentStudent(),
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/candidate-profile.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
jobpilot:
candidate-profile:
# Stable key of the persistent candidate owning this profile; seeded by migration V14.
# Ownership only: it is not a candidate fact and does not affect the profile source hash.
candidate-key: default
profile-version: 1
full-name: Pavlo Sushkov
location: Bucharest, Romania
Expand Down
39 changes: 39 additions & 0 deletions src/main/resources/db/migration/V14__candidate_ownership.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Multi-user foundation, step 1: candidate_profiles gains an explicit owner.
--
-- This migration establishes ownership ONLY. profile_version and active_slot are still
-- globally UNIQUE exactly as V2 declared them, so the schema continues to admit one active
-- profile and one profile per version for the whole installation rather than per candidate.
-- Those two constraints are the remaining single-candidate assumption and are rewritten in a
-- dedicated follow-up; nothing added here depends on them.

CREATE TABLE candidates (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
stable_key VARCHAR(100) NOT NULL UNIQUE CHECK (TRIM(stable_key) <> ''),
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);

-- The owner of every profile that exists today. 'default' is the value committed as
-- jobpilot.candidate-profile.candidate-key. Bootstrap resolves this row by its stable key and
-- never by id, so the seeded identity holds whatever id the sequence hands out here.
INSERT INTO candidates (stable_key, created_at, updated_at)
VALUES ('default', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

ALTER TABLE candidate_profiles ADD COLUMN candidate_id BIGINT;

-- Backfills every stored profile version, active and superseded alike: history belongs to the
-- same candidate as the current version. On a fresh database this updates zero rows, because
-- bootstrap only runs once the application starts, and the NOT NULL below still holds.
UPDATE candidate_profiles
SET candidate_id = (SELECT id FROM candidates WHERE stable_key = 'default')
WHERE candidate_id IS NULL;

ALTER TABLE candidate_profiles ALTER COLUMN candidate_id SET NOT NULL;

-- RESTRICT, not CASCADE: profile versions are referenced by resume_versions under the same
-- rule, and no code path deletes a candidate.
ALTER TABLE candidate_profiles ADD CONSTRAINT candidate_profiles_candidate_fk
FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE RESTRICT;

-- Every candidate-scoped profile lookup filters on this column.
CREATE INDEX candidate_profiles_candidate_idx ON candidate_profiles(candidate_id);
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ public final class CandidateProfileTestData {
private CandidateProfileTestData() {
}

public static final String CANDIDATE_KEY = "default";

public static CandidateProfileProperties validProfile(int version) {
return new CandidateProfileProperties(
CANDIDATE_KEY,
version,
"Test Candidate",
"Bucharest, Romania",
Expand Down Expand Up @@ -60,6 +63,17 @@ public static CandidateProfileProperties withFullName(CandidateProfileProperties
source.projects());
}

/** Changes only the owning candidate, leaving every candidate fact untouched. */
public static CandidateProfileProperties withCandidateKey(CandidateProfileProperties source,
String candidateKey) {
return new CandidateProfileProperties(candidateKey, source.profileVersion(),
source.fullName(), source.location(), source.educationInstitution(),
source.degree(), source.studyStartYear(), source.studyEndYear(),
source.currentStudent(), source.finalYearStudent(),
source.commercialJavaExperienceYears(), source.skills(), source.languages(),
source.projects());
}

public static CandidateProfileProperties withVersion(CandidateProfileProperties source,
int version) {
return copy(source, version, source.fullName(), source.commercialJavaExperienceYears(),
Expand Down Expand Up @@ -98,7 +112,7 @@ private static CandidateProfileProperties copy(
BigDecimal experience, List<CandidateProfileProperties.Skill> skills,
List<CandidateProfileProperties.Language> languages,
List<CandidateProfileProperties.Project> projects) {
return new CandidateProfileProperties(version, fullName, source.location(),
return new CandidateProfileProperties(source.candidateKey(), version, fullName, source.location(),
source.educationInstitution(), source.degree(), source.studyStartYear(),
source.studyEndYear(), source.currentStudent(), source.finalYearStudent(),
experience, skills, languages, projects);
Expand Down
Loading
Loading