package com.mydreamjob.onboarding; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import jakarta.persistence.PersistenceContext; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; import com.mydreamjob.onboarding.dto.ReviewSummaryView; import com.mydreamjob.review.JobReviewDimensionScoreRepository; import com.mydreamjob.review.JobReviewRepository; import com.mydreamjob.review.ReviewDimension; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.regex.Pattern; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class CompanyKnowledgeService { private static final String CONFIGURED = "CONFIGURED"; private static final String BASIC_ONLY = "BASIC_ONLY"; private static final String EMPTY = "EMPTY"; private static final String INSUFFICIENT = "INSUFFICIENT"; private static final String SUFFICIENT = "SUFFICIENT"; private static final int MINIMUM_PUBLIC_REVIEWS = 3; private static final int MAX_TOP_TAGS = 10; private static final int MAX_CULTURE_LENGTH = 2000; private static final int MAX_WORK_POLICIES_LENGTH = 4000; private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000; private static final Pattern HTML_TAG_PATTERN = Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>"); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final UserRepository userRepository; private final CompanyRepository companyRepository; private final CompanyKnowledgeRepository companyKnowledgeRepository; private final JobReviewRepository jobReviewRepository; private final JobReviewDimensionScoreRepository dimensionScoreRepository; @PersistenceContext private EntityManager entityManager; @Transactional(readOnly = true) public CompanyKnowledgeView getCompanyKnowledge(Long userId) { Company company = requireOwnedCompany(userId); return companyKnowledgeRepository.findByCompanyId(company.getId()) .map(this::toConfiguredView) .orElseGet(() -> CompanyKnowledgeView.builder() .configured(false) .version(0L) .build()); } @Transactional public CompanyKnowledgeView upsertCompanyKnowledge( Long userId, CompanyKnowledgeUpdateRequest request) { Company company = requireOwnedCompany(userId); KnowledgeContent content = validateContent(request); lockCompany(company); CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) .orElse(null); if (existing == null) { CompanyKnowledge created = CompanyKnowledge.builder() .companyId(company.getId()) .culture(content.culture()) .workPolicies(content.workPolicies()) .onboardingGuide(content.onboardingGuide()) .build(); return toConfiguredView(saveKnowledge(created)); } if (request.getVersion() == null && matchesContent(existing, content)) { return toConfiguredView(existing); } if (request.getVersion() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } if (!Objects.equals(request.getVersion(), existing.getVersion())) { throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE); } existing.setCulture(content.culture()); existing.setWorkPolicies(content.workPolicies()); existing.setOnboardingGuide(content.onboardingGuide()); return toConfiguredView(saveKnowledge(existing)); } @Transactional(readOnly = true) public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview( Long userId, Long companyId) { assertStudentRole(userId); Company company = companyRepository.findById(companyId) .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId) .orElse(null); return CompanyKnowledgeOverviewResponse.builder() .company(toCompanyView(company)) .knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED) .knowledge(knowledge == null ? null : toKnowledgeView(knowledge)) .reviewSummary(buildReviewSummary(companyId)) .build(); } private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { try { return companyKnowledgeRepository.saveAndFlush(knowledge); } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { throw new BusinessException( ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); } } private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) { return Objects.equals(knowledge.getCulture(), content.culture()) && Objects.equals(knowledge.getWorkPolicies(), content.workPolicies()) && Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide()); } private void lockCompany(Company company) { if (entityManager == null || company == null || company.getId() == null) { return; } entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); } private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) { if (request == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } if (request.getVersion() != null && request.getVersion() < 0) { throw new BusinessException(ErrorCode.BAD_REQUEST); } String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH); String workPolicies = normalizeText( request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH); String onboardingGuide = normalizeText( request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH); if (culture == null && workPolicies == null && onboardingGuide == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } return new KnowledgeContent(culture, workPolicies, onboardingGuide); } private String normalizeText(String raw, int maxLength) { if (!StringUtils.hasText(raw)) { return null; } String value = raw.trim(); if (value.length() > maxLength) { throw new BusinessException(ErrorCode.BAD_REQUEST); } if (HTML_TAG_PATTERN.matcher(value).find()) { throw new BusinessException(ErrorCode.BAD_REQUEST); } return value; } private ReviewSummaryView buildReviewSummary(Long companyId) { List countRows = jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); long totalReviews = extractCount(countRows); if (totalReviews == 0) { return ReviewSummaryView.builder() .state(EMPTY) .totalReviews(0) .build(); } if (totalReviews < MINIMUM_PUBLIC_REVIEWS) { return ReviewSummaryView.builder() .state(INSUFFICIENT) .totalReviews(Math.toIntExact(totalReviews)) .build(); } return ReviewSummaryView.builder() .state(SUFFICIENT) .totalReviews(Math.toIntExact(totalReviews)) .averageRating(extractAverageRating(countRows)) .ratingDistribution(buildRatingDistribution(companyId)) .topTags(buildTopTags(companyId)) .dimensionSummary(buildDimensionSummary(companyId)) .build(); } private long extractCount(List rows) { if (rows == null || rows.isEmpty() || rows.get(0) == null || rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) { return 0L; } return count.longValue(); } private Double extractAverageRating(List rows) { if (rows == null || rows.isEmpty() || rows.get(0) == null || rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) { return 0.0; } return BigDecimal.valueOf(average.doubleValue()) .setScale(1, RoundingMode.HALF_UP) .doubleValue(); } private Map buildRatingDistribution(Long companyId) { Map distribution = new LinkedHashMap<>(); for (int rating = 1; rating <= 5; rating++) { distribution.put("rating" + rating, 0); } List rows = jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId); if (rows == null) { return distribution; } for (Object[] row : rows) { if (row == null || row.length < 2 || !(row[0] instanceof Number rating) || !(row[1] instanceof Number count)) { continue; } int ratingValue = rating.intValue(); if (ratingValue >= 1 && ratingValue <= 5) { distribution.put("rating" + ratingValue, count.intValue()); } } return distribution; } private List buildTopTags(Long companyId) { Map counts = new HashMap<>(); List rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId); if (rawTags != null) { for (String rawTag : rawTags) { for (String tag : parseTags(rawTag)) { counts.merge(tag, 1L, Long::sum); } } } return counts.entrySet().stream() .sorted((left, right) -> { int byCount = Long.compare(right.getValue(), left.getValue()); return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey()); }) .limit(MAX_TOP_TAGS) .map(Map.Entry::getKey) .toList(); } private List parseTags(String raw) { if (!StringUtils.hasText(raw)) { return List.of(); } Object parsed = parseJson(raw); for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) { parsed = parseJson(nested); } if (!(parsed instanceof Collection values)) { return List.of(); } List tags = new ArrayList<>(); for (Object value : values) { if (value instanceof String tag && StringUtils.hasText(tag)) { tags.add(tag.trim()); } } return tags; } private Object parseJson(String raw) { try { return OBJECT_MAPPER.readValue(raw, Object.class); } catch (JsonProcessingException ex) { return null; } } private Map buildDimensionSummary( Long companyId) { Map rowsByCode = new HashMap<>(); List rows = dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); if (rows != null) { for (Object[] row : rows) { if (row != null && row.length >= 3 && row[0] instanceof String code) { rowsByCode.put(code, row); } } } Map result = new LinkedHashMap<>(); for (ReviewDimension dimension : ReviewDimension.values()) { Object[] row = rowsByCode.get(dimension.name()); long sampleCount = 0L; Double averageScore = null; if (row != null && row[2] instanceof Number count) { sampleCount = count.longValue(); } if (row != null && row[1] instanceof Number average) { averageScore = BigDecimal.valueOf(average.doubleValue()) .setScale(1, RoundingMode.HALF_UP) .doubleValue(); } result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder() .averageScore(averageScore) .sampleCount(sampleCount) .dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS ? SUFFICIENT : INSUFFICIENT) .build()); } return result; } private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) { return CompanyKnowledgeView.builder() .configured(true) .culture(knowledge.getCulture()) .workPolicies(knowledge.getWorkPolicies()) .onboardingGuide(knowledge.getOnboardingGuide()) .updatedAt(knowledge.getUpdatedAt()) .version(knowledge.getVersion() == null ? 0L : knowledge.getVersion()) .build(); } private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView( CompanyKnowledge knowledge) { return CompanyKnowledgeOverviewResponse.KnowledgeView.builder() .culture(knowledge.getCulture()) .workPolicies(knowledge.getWorkPolicies()) .onboardingGuide(knowledge.getOnboardingGuide()) .updatedAt(knowledge.getUpdatedAt()) .build(); } private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) { return CompanyKnowledgeOverviewResponse.CompanyView.builder() .id(company.getId()) .name(company.getName()) .logoUrl(company.getLogoUrl()) .description(company.getDescription()) .industry(company.getIndustry()) .website(company.getWebsite()) .address(company.getAddress()) .verified(company.getVerified()) .build(); } private Company requireOwnedCompany(Long userId) { assertCompanyRole(userId); return companyRepository.findByOwnerUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); } private void assertCompanyRole(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); if (user.getRoles() == null || user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) { throw new BusinessException(ErrorCode.FORBIDDEN); } } private void assertStudentRole(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); if (user.getRoles() == null || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { throw new BusinessException(ErrorCode.FORBIDDEN); } } private record KnowledgeContent( String culture, String workPolicies, String onboardingGuide) { } }