diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java index 7eea4fb..d8cba27 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -56,6 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/api/resources", "/api/resources/**").permitAll() .requestMatchers(publicEndpoints).permitAll() .anyRequest().authenticated() ) diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java new file mode 100644 index 0000000..f83327e --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -0,0 +1,108 @@ +package com.thughari.jobtrackerpro.controller; + +import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.dto.CareerResourcePageResponse; +import com.thughari.jobtrackerpro.dto.CreateCareerResourceRequest; +import com.thughari.jobtrackerpro.dto.UpdateCareerResourceRequest; +import com.thughari.jobtrackerpro.service.CareerResourceService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; +import java.util.List; + +@RestController +@RequestMapping("/api/resources") +public class CareerResourceController { + + private final CareerResourceService careerResourceService; + + public CareerResourceController(CareerResourceService careerResourceService) { + this.careerResourceService = careerResourceService; + } + + @GetMapping + public ResponseEntity getResources( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String query, + @RequestParam(required = false) String category, + @RequestParam(required = false) String type + ) { + return ResponseEntity.ok(careerResourceService.getResourcePage(page, size, query, category, type, getAuthenticatedEmailOrNull())); + } + + + @GetMapping("/categories") + public ResponseEntity> getCategories() { + return ResponseEntity.ok(careerResourceService.getAllCategories()); + } + + @PostMapping + public ResponseEntity addResource(@RequestBody CreateCareerResourceRequest request) { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.createResource(email, request)); + } + + @GetMapping("/mine") + public ResponseEntity> getMyResources() { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.getMyResources(email)); + } + + @PutMapping("/{id}") + public ResponseEntity updateResource(@PathVariable UUID id, + @RequestBody UpdateCareerResourceRequest request) { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.updateResource(email, id, request)); + } + + @PostMapping(path = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadResource( + @RequestParam String title, + @RequestParam String category, + @RequestParam(required = false) String description, + @RequestParam MultipartFile file + ) { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.createResourceFromFile(email, title, category, description, file)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteResource(@PathVariable UUID id) { + String email = getAuthenticatedEmail(); + careerResourceService.deleteResource(email, id); + return ResponseEntity.noContent().build(); + } + + private String getAuthenticatedEmail() { + return ((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).toLowerCase(); + } + + private String getAuthenticatedEmailOrNull() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() || authentication instanceof AnonymousAuthenticationToken) { + return null; + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof String email && !"anonymousUser".equalsIgnoreCase(email)) { + return email.toLowerCase(); + } + + return null; + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java new file mode 100644 index 0000000..91147a1 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java @@ -0,0 +1,21 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +public class CareerResourceDTO { + private UUID id; + private String title; + private String url; + private String category; + private String description; + private String resourceType; + private String originalFileName; + private Long fileSizeBytes; + private boolean ownedByCurrentUser; + private String submittedByName; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java new file mode 100644 index 0000000..ea235ba --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java @@ -0,0 +1,17 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class CareerResourcePageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java new file mode 100644 index 0000000..3dfd817 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java @@ -0,0 +1,11 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +@Data +public class CreateCareerResourceRequest { + private String title; + private String url; + private String category; + private String description; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java new file mode 100644 index 0000000..dd71b87 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java @@ -0,0 +1,11 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +@Data +public class UpdateCareerResourceRequest { + private String title; + private String url; + private String category; + private String description; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java new file mode 100644 index 0000000..0472131 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java @@ -0,0 +1,60 @@ +package com.thughari.jobtrackerpro.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Entity +@Table(name = "career_resources") +public class CareerResource { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, length = 180) + private String title; + + @Column(nullable = false, length = 2048) + private String url; + + @Column(nullable = false, length = 16) + private String resourceType = "LINK"; + + @Column(nullable = false, length = 80) + private String category; + + @Column(length = 1200) + private String description; + + @Column(length = 255) + private String originalFileName; + + private Long fileSizeBytes; + + @Column(nullable = false) + private String submittedByEmail; + + @Column(nullable = false) + private String submittedByName; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java index 0610324..25ebe87 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java @@ -6,6 +6,7 @@ public interface StorageService { String uploadFile(MultipartFile file, String userId); String uploadFromUrl(String externalUrl, String userId); + String uploadResourceFile(MultipartFile file, String userId); void deleteFile(String fileUrl); } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java new file mode 100644 index 0000000..1d26336 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java @@ -0,0 +1,24 @@ +package com.thughari.jobtrackerpro.repo; + +import com.thughari.jobtrackerpro.entity.CareerResource; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.UUID; + +public interface CareerResourceRepository extends JpaRepository, JpaSpecificationExecutor { + List findAllByOrderByCreatedAtDesc(); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + List findAllBySubmittedByEmailOrderByCreatedAtDesc(String email); + + @Query("select distinct r.category from CareerResource r where r.category is not null and trim(r.category) <> '' order by r.category asc") + List findDistinctCategories(); + + boolean existsByUrl(String url); +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java new file mode 100644 index 0000000..e9297a6 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java @@ -0,0 +1,344 @@ +package com.thughari.jobtrackerpro.service; + +import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.dto.CareerResourcePageResponse; +import com.thughari.jobtrackerpro.dto.CreateCareerResourceRequest; +import com.thughari.jobtrackerpro.dto.UpdateCareerResourceRequest; +import com.thughari.jobtrackerpro.entity.CareerResource; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.interfaces.StorageService; +import com.thughari.jobtrackerpro.repo.CareerResourceRepository; +import com.thughari.jobtrackerpro.repo.UserRepository; +import jakarta.annotation.PostConstruct; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.persistence.criteria.Predicate; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.List; + +@Service +@Transactional +public class CareerResourceService { + + private static final int MAX_PAGE_SIZE = 50; + private static final int DEMO_MIN_RESOURCE_COUNT = 60; + + private final CareerResourceRepository resourceRepository; + private final UserRepository userRepository; + private final StorageService storageService; + + public CareerResourceService(CareerResourceRepository resourceRepository, + UserRepository userRepository, + StorageService storageService) { + this.resourceRepository = resourceRepository; + this.userRepository = userRepository; + this.storageService = storageService; + } + + @Transactional(readOnly = true) + @Cacheable(value = "resourcePages", key = "{#page, #size, #query == null ? '' : #query, #category == null ? '' : #category, #type == null ? '' : #type, #viewerEmail == null ? 'anon' : #viewerEmail}") + public CareerResourcePageResponse getResourcePage(int page, + int size, + String query, + String category, + String type, + String viewerEmail) { + int sanitizedPage = Math.max(0, page); + int sanitizedSize = Math.max(1, Math.min(size, MAX_PAGE_SIZE)); + String normalizedQuery = normalizeFilter(query); + String normalizedCategory = normalizeFilter(category); + String normalizedType = normalizeType(type); + + var pageable = PageRequest.of(sanitizedPage, sanitizedSize, Sort.by(Sort.Direction.DESC, "createdAt")); + var resourcePage = resourceRepository.findAll(buildResourceFilter(normalizedQuery, normalizedCategory, normalizedType), pageable); + + var content = resourcePage.getContent() + .stream() + .map(resource -> toDTO(resource, viewerEmail)) + .toList(); + + return new CareerResourcePageResponse( + content, + resourcePage.getNumber(), + resourcePage.getSize(), + resourcePage.getTotalElements(), + resourcePage.getTotalPages(), + resourcePage.hasNext() + ); + } + + @Transactional(readOnly = true) + public List getAllCategories() { + return resourceRepository.findDistinctCategories(); + } + + private Specification buildResourceFilter(String query, String category, String type) { + return (root, criteriaQuery, criteriaBuilder) -> { + var predicates = new ArrayList(); + + if (query != null) { + String likeQuery = "%" + query.toLowerCase(Locale.ROOT) + "%"; + predicates.add(criteriaBuilder.or( + criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), likeQuery), + criteriaBuilder.like(criteriaBuilder.lower(root.get("category")), likeQuery), + criteriaBuilder.like(criteriaBuilder.lower(root.get("description")), likeQuery), + criteriaBuilder.like(criteriaBuilder.lower(root.get("submittedByName")), likeQuery) + )); + } + + if (category != null) { + String likeCategory = "%" + category.toLowerCase(Locale.ROOT) + "%"; + predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("category")), likeCategory)); + } + + if (type != null) { + predicates.add(criteriaBuilder.equal(criteriaBuilder.upper(root.get("resourceType")), type)); + } + + return predicates.isEmpty() + ? criteriaBuilder.conjunction() + : criteriaBuilder.and(predicates.toArray(new Predicate[0])); + }; + } + + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + if (trimmed.isEmpty() || "all".equalsIgnoreCase(trimmed)) { + return null; + } + + return trimmed; + } + + private String normalizeType(String value) { + String normalized = normalizeFilter(value); + if (normalized == null) { + return null; + } + + String upper = normalized.toUpperCase(Locale.ROOT); + if (!upper.equals("LINK") && !upper.equals("FILE")) { + return null; + } + + return upper; + } + + @CacheEvict(value = "resourcePages", allEntries = true) + public CareerResourceDTO createResource(String email, CreateCareerResourceRequest request) { + String normalizedUrl = normalizeUrl(request.getUrl()); + validateLinkPayload(request, normalizedUrl); + + User user = getUser(email); + + CareerResource resource = new CareerResource(); + resource.setTitle(request.getTitle().trim()); + resource.setUrl(normalizedUrl); + resource.setCategory(request.getCategory().trim()); + resource.setDescription(request.getDescription() == null ? null : request.getDescription().trim()); + resource.setResourceType("LINK"); + applySubmitter(resource, user); + + return toDTO(resourceRepository.save(resource), email); + } + + @CacheEvict(value = "resourcePages", allEntries = true) + public CareerResourceDTO createResourceFromFile(String email, + String title, + String category, + String description, + MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please choose a file to upload"); + } + validateCommonFields(title, category); + + User user = getUser(email); + String fileUrl = storageService.uploadResourceFile(file, user.getId().toString()); + + CareerResource resource = new CareerResource(); + resource.setTitle(title.trim()); + resource.setCategory(category.trim()); + resource.setDescription(description == null ? null : description.trim()); + resource.setUrl(fileUrl); + resource.setResourceType("FILE"); + resource.setOriginalFileName(file.getOriginalFilename()); + resource.setFileSizeBytes(file.getSize()); + applySubmitter(resource, user); + + return toDTO(resourceRepository.save(resource), email); + } + + @CacheEvict(value = "resourcePages", allEntries = true) + public void deleteResource(String email, java.util.UUID resourceId) { + CareerResource resource = resourceRepository.findById(resourceId) + .orElseThrow(() -> new IllegalArgumentException("Resource not found")); + + if (!resource.getSubmittedByEmail().equalsIgnoreCase(email)) { + throw new IllegalArgumentException("You can only delete resources you added"); + } + + if ("FILE".equalsIgnoreCase(resource.getResourceType())) { + storageService.deleteFile(resource.getUrl()); + } + + resourceRepository.delete(resource); + } + + @Transactional(readOnly = true) + public java.util.List getMyResources(String email) { + return resourceRepository.findAllBySubmittedByEmailOrderByCreatedAtDesc(email) + .stream() + .map(resource -> toDTO(resource, email)) + .toList(); + } + + @CacheEvict(value = "resourcePages", allEntries = true) + public CareerResourceDTO updateResource(String email, java.util.UUID resourceId, UpdateCareerResourceRequest request) { + CareerResource resource = resourceRepository.findById(resourceId) + .orElseThrow(() -> new IllegalArgumentException("Resource not found")); + + if (!resource.getSubmittedByEmail().equalsIgnoreCase(email)) { + throw new IllegalArgumentException("You can only edit resources you added"); + } + + validateCommonFields(request.getTitle(), request.getCategory()); + + resource.setTitle(request.getTitle().trim()); + resource.setCategory(request.getCategory().trim()); + resource.setDescription(request.getDescription() == null ? null : request.getDescription().trim()); + + if ("LINK".equalsIgnoreCase(resource.getResourceType())) { + String normalizedUrl = normalizeUrl(request.getUrl()); + if (normalizedUrl == null || normalizedUrl.isBlank()) { + throw new IllegalArgumentException("Valid URL is required for link resources"); + } + resource.setUrl(normalizedUrl); + } + + return toDTO(resourceRepository.save(resource), email); + } + + @PostConstruct + public void seedStarterResources() { + addSeedResource("Career Preparation Notes", "https://docs.google.com/document/d/1-25JrPUai6P7pjKk1g7mELpI0YUINS_NpjUk7lsMfyg/edit?tab=t.0#heading=h.kf8l3f8jftc2", "Guides & Study Docs"); + addSeedResource("Main Career Resources Folder", "https://drive.google.com/drive/folders/1ISp9GBv7ih1blEQOPYplD_idG1j0ibGq?usp=sharing", "Drive Folders & File Packs"); + addSeedResource("DSA Folder", "https://drive.google.com/drive/folders/1ei52Zc_cQe0rJK404M56BmEisUjnF6kN?usp=drive_link", "Drive Folders & File Packs"); + addSeedResource("21 Days React Study Plan", "https://thecodedose.notion.site/21-Days-React-Study-Plan-1988ff023cae48459bae8cb20cb75a67", "Structured Learning"); + addSeedResource("Opportunity Tracker Sheet", "https://docs.google.com/spreadsheets/d/1KBFiqJTaFY1164XtglKvn2vAofScCfGlkY-n54D2d14/edit?gid=584790886#gid=584790886", "Trackers & Opportunity Sheets"); + + seedDemoVolumeResources(); + } + + private void seedDemoVolumeResources() { + long currentCount = resourceRepository.count(); + if (currentCount >= DEMO_MIN_RESOURCE_COUNT) { + return; + } + + String[][] templates = { + {"React Interview Drill", "https://example.com/resources/react-interview-drill", "Interview Prep"}, + {"DSA Patterns Workbook", "https://example.com/resources/dsa-patterns-workbook", "DSA"}, + {"System Design Primer", "https://example.com/resources/system-design-primer", "System Design"}, + {"Resume Bullet Bank", "https://example.com/resources/resume-bullet-bank", "Resume"}, + {"Backend Fundamentals Roadmap", "https://example.com/resources/backend-fundamentals-roadmap", "Roadmaps"}, + {"Frontend Fundamentals Roadmap", "https://example.com/resources/frontend-fundamentals-roadmap", "Roadmaps"}, + {"Mock Interview Checklist", "https://example.com/resources/mock-interview-checklist", "Mock Interviews"}, + {"Job Board Tracker", "https://example.com/resources/job-board-tracker", "Job Boards"}, + {"Behavioral Question Matrix", "https://example.com/resources/behavioral-question-matrix", "Interview Prep"}, + {"Portfolio Project Ideas", "https://example.com/resources/portfolio-project-ideas", "Portfolio"} + }; + + long resourcesToAdd = DEMO_MIN_RESOURCE_COUNT - currentCount; + for (int i = 0; i < resourcesToAdd; i++) { + String[] template = templates[i % templates.length]; + String title = template[0] + " #" + (i + 1); + String url = template[1] + "?v=" + (i + 1); + String category = template[2]; + + addSeedResource(title, url, category); + } + } + + private void addSeedResource(String title, String url, String category) { + if (resourceRepository.existsByUrl(url)) { + return; + } + + CareerResource resource = new CareerResource(); + resource.setTitle(title); + resource.setUrl(url); + resource.setCategory(category); + resource.setDescription("Seeded starter resource"); + resource.setResourceType("LINK"); + resource.setSubmittedByEmail("system@jobtrackerpro.local"); + resource.setSubmittedByName("JobTrackerPro"); + resourceRepository.save(resource); + } + + private User getUser(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + } + + private void applySubmitter(CareerResource resource, User user) { + resource.setSubmittedByEmail(user.getEmail()); + resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); + } + + private void validateLinkPayload(CreateCareerResourceRequest request, String normalizedUrl) { + validateCommonFields(request.getTitle(), request.getCategory()); + if (normalizedUrl == null || normalizedUrl.isBlank()) { + throw new IllegalArgumentException("Valid URL is required"); + } + } + + private void validateCommonFields(String title, String category) { + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("Title is required"); + } + if (category == null || category.isBlank()) { + throw new IllegalArgumentException("Category is required"); + } + } + + private String normalizeUrl(String url) { + if (url == null) { + return null; + } + String trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return "https://" + trimmed; + } + return trimmed; + } + + private CareerResourceDTO toDTO(CareerResource resource, String viewerEmail) { + CareerResourceDTO dto = new CareerResourceDTO(); + dto.setId(resource.getId()); + dto.setTitle(resource.getTitle()); + dto.setUrl(resource.getUrl()); + dto.setCategory(resource.getCategory()); + dto.setDescription(resource.getDescription()); + dto.setResourceType(resource.getResourceType()); + dto.setOriginalFileName(resource.getOriginalFileName()); + dto.setFileSizeBytes(resource.getFileSizeBytes()); + dto.setOwnedByCurrentUser(viewerEmail != null && resource.getSubmittedByEmail().equalsIgnoreCase(viewerEmail)); + dto.setSubmittedByName(resource.getSubmittedByName()); + dto.setCreatedAt(resource.getCreatedAt()); + return dto; + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java index 080ea4b..f119d48 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java @@ -28,8 +28,9 @@ public class CloudStorageService implements StorageService { private final S3Client s3Client; - - private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; + + private static final long MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024; + private static final long MAX_RESOURCE_FILE_SIZE = 10 * 1024 * 1024; @Value("${cloudflare.r2.bucket}") private String bucketName; @@ -42,19 +43,19 @@ public CloudStorageService(S3Client s3Client) { } public String uploadFile(MultipartFile file, String userId) { - - String contentType = file.getContentType(); + + String contentType = file.getContentType(); if (!isValidImageContent(contentType)) { throw new InvalidImageException("Invalid file type. Only JPG, PNG, GIF, WEBP are allowed."); } - - if (file.getSize() > MAX_FILE_SIZE) { - throw new MaxUploadSizeExceededException(0); + + if (file.getSize() > MAX_IMAGE_FILE_SIZE) { + throw new MaxUploadSizeExceededException(MAX_IMAGE_FILE_SIZE); } try { String extension = getExtensionFromContentType(file.getContentType()); String fileName = userId + "-" + System.currentTimeMillis() + extension; - + PutObjectRequest putObj = PutObjectRequest.builder() .bucket(bucketName) .key(fileName) @@ -65,13 +66,43 @@ public String uploadFile(MultipartFile file, String userId) { return publicUrl + "/" + fileName; } catch (Exception e) { - log.error("Failed to upload to R2: " + e.getLocalizedMessage()); + log.error("Failed to upload to R2: " + e.getLocalizedMessage()); throw new RuntimeException("Failed to upload to R2", e); } } + @Override + public String uploadResourceFile(MultipartFile file, String userId) { + String contentType = file.getContentType(); + if (!isValidResourceFileType(contentType, file.getOriginalFilename())) { + throw new IllegalArgumentException("Invalid file type. Only PDF, DOC, DOCX and TXT are allowed."); + } + + if (file.getSize() > MAX_RESOURCE_FILE_SIZE) { + throw new MaxUploadSizeExceededException(MAX_RESOURCE_FILE_SIZE); + } + + try { + String extension = getExtensionFromFilename(file.getOriginalFilename()); + String fileName = "resources/" + userId + "-" + System.currentTimeMillis() + extension; + + PutObjectRequest putObj = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .contentType(contentType) + .build(); + + s3Client.putObject(putObj, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return publicUrl + "/" + fileName; + } catch (Exception e) { + log.error("Failed to upload resource file to R2", e); + throw new RuntimeException("Failed to upload resource file", e); + } + } + public String uploadFromUrl(String externalUrl, String userId) { - if (externalUrl == null || !externalUrl.startsWith("http")) { + if (externalUrl == null || !externalUrl.startsWith("http")) { throw new InvalidImageException("Invalid URL format"); } try { @@ -89,18 +120,18 @@ public String uploadFromUrl(String externalUrl, String userId) { client.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { - log.error("Failed to download image."); - throw new ResourceNotFoundException("provided url is not accessible"); + log.error("Failed to download image."); + throw new ResourceNotFoundException("provided url is not accessible"); } String contentType = response.headers().firstValue("Content-Type").orElse(""); - + if (!isValidImageContent(contentType)) { throw new InvalidImageException("URL does not point to a valid image (Type: " + contentType + ")"); } - + response.headers().firstValue("Content-Length").ifPresent(len -> { - if (Long.parseLong(len) > MAX_FILE_SIZE) { + if (Long.parseLong(len) > MAX_IMAGE_FILE_SIZE) { throw new IllegalArgumentException("Image at URL is too large"); } }); @@ -123,19 +154,18 @@ public String uploadFromUrl(String externalUrl, String userId) { } catch (InvalidImageException | IllegalArgumentException | ResourceNotFoundException e) { throw e; - } - catch (Exception e) { + } catch (Exception e) { log.error("Failed to upload image From Url to R2", e); throw new InvalidImageException("unable to update image"); } } - + public void deleteFile(String fileUrl) { if (fileUrl == null || fileUrl.isEmpty()) { return; } if (!fileUrl.startsWith(publicUrl)) { - return; + return; } try { @@ -145,7 +175,7 @@ public void deleteFile(String fileUrl) { .bucket(bucketName) .key(key) .build()); - + log.info("Deleted old image from R2: {}", key); } catch (Exception e) { @@ -153,10 +183,9 @@ public void deleteFile(String fileUrl) { } } - private String getExtensionFromContentType(String contentType) { if (contentType == null) return ".jpg"; - + return switch (contentType.toLowerCase()) { case "image/png" -> ".png"; case "image/jpeg", "image/jpg" -> ".jpg"; @@ -165,12 +194,38 @@ private String getExtensionFromContentType(String contentType) { default -> ".jpg"; }; } - + + private String getExtensionFromFilename(String filename) { + if (filename == null || !filename.contains(".")) { + return ".pdf"; + } + return filename.substring(filename.lastIndexOf('.')).toLowerCase(); + } + private boolean isValidImageContent(String contentType) { if (contentType == null) return false; return contentType.equals("image/jpeg") || - contentType.equals("image/png") || - contentType.equals("image/gif") || - contentType.equals("image/webp"); + contentType.equals("image/png") || + contentType.equals("image/gif") || + contentType.equals("image/webp"); + } + + private boolean isValidResourceFileType(String contentType, String filename) { + String ext = getExtensionFromFilename(filename); + boolean extAllowed = ext.equals(".pdf") || ext.equals(".doc") || ext.equals(".docx") || ext.equals(".txt"); + + if (!extAllowed) { + return false; + } + + if (contentType == null || contentType.isBlank()) { + return true; + } + + return contentType.equals("application/pdf") || + contentType.equals("application/msword") || + contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document") || + contentType.equals("text/plain") || + contentType.equals("application/octet-stream"); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java index a8acb10..c83dac4 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartFile; import java.io.File; @@ -18,6 +19,8 @@ public class LocalStorageService implements StorageService { @Value("${app.base-url}") private String baseUrl; + private static final long MAX_RESOURCE_FILE_SIZE = 10 * 1024 * 1024; + private final String UPLOAD_DIR = "uploads"; public LocalStorageService() { @@ -31,13 +34,37 @@ public String uploadFile(MultipartFile file, String userId) { String fileName = userId + "-" + System.currentTimeMillis() + ".jpg"; Path path = Paths.get(UPLOAD_DIR, fileName); Files.write(path, file.getBytes()); - + return baseUrl + "/api/storage/files/" + fileName; } catch (Exception e) { throw new RuntimeException("Local upload failed", e); } } + @Override + public String uploadResourceFile(MultipartFile file, String userId) { + try { + if (file.getSize() > MAX_RESOURCE_FILE_SIZE) { + throw new MaxUploadSizeExceededException(MAX_RESOURCE_FILE_SIZE); + } + + String extension = getExtensionFromFilename(file.getOriginalFilename()); + if (!isAllowedResourceExtension(extension)) { + throw new IllegalArgumentException("Invalid file type. Only PDF, DOC, DOCX and TXT are allowed."); + } + + String fileName = "resources-" + userId + "-" + System.currentTimeMillis() + extension; + Path path = Paths.get(UPLOAD_DIR, fileName); + Files.write(path, file.getBytes()); + + return baseUrl + "/api/storage/files/" + fileName; + } catch (IllegalArgumentException | MaxUploadSizeExceededException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Local resource upload failed", e); + } + } + @Override public String uploadFromUrl(String externalUrl, String userId) { return externalUrl; @@ -53,4 +80,15 @@ public void deleteFile(String fileUrl) { System.err.println("Could not delete local file: " + e.getMessage()); } } -} \ No newline at end of file + + private boolean isAllowedResourceExtension(String extension) { + return ".pdf".equals(extension) || ".doc".equals(extension) || ".docx".equals(extension) || ".txt".equals(extension); + } + + private String getExtensionFromFilename(String fileName) { + if (fileName == null || !fileName.contains(".")) { + return ".pdf"; + } + return fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); + } +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index eb21e9e..687297f 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -15,6 +15,7 @@ + } + + +
+ + + + +
+ Filter by type +
+ + + +
+
+
+ + @if (saveMessage()) { +

{{ saveMessage() }}

+ } + + + @if (authService.isAuthenticated()) { +
+
+
+

Your shared resources

+

Edit or remove resources you have contributed.

+
+ +
+ + @if (isLoadingMyResources()) { +

Loading your resources...

+ } @else if (!myResources().length) { +

You have not shared any resources yet.

+ } @else { +
    + @for (resource of myResources(); track resource.id) { +
  • +
    +

    {{ resource.title }}

    +

    {{ resource.category }} · {{ resource.resourceType === 'FILE' ? 'File' : 'Link' }}

    +
    +
    + + +
    +
  • + } +
+ } + + @if (editMessage()) { +

{{ editMessage() }}

+ } +
+ } + + @if (!authService.isAuthenticated()) { +
+ Want to contribute? Log in and add links or files. +
+ } + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + @if (isLoading()) { +

Loading resources...

+ } @else if (!groupedResources().length) { +
+ No resources matched your search/filter. Try adjusting the filters. +
+ } @else { +
+ @for (section of groupedResources(); track section.category) { + + } +
+ + @if (hasNext()) { +
+ +
+ } + } + + + @if (showAddResourceModal()) { +
+
+
+
+

Add a resource

+

Share a helpful link or upload a file (max 10MB).

+
+ +
+ +
+ + +
+ +
+ + +
+ + @if (contributionMode === 'link') { + + } @else { + + } + + + +
+ + +
+
+
+ } + + @if (editingResource()) { +
+
+
+
+

Edit resource

+

Update your resource details.

+
+ +
+ +
+ + +
+ + @if (editingResource()?.resourceType === 'LINK') { + + } @else { +

File URL cannot be edited for uploaded resources. You can edit title/category/description.

+ } + + + +
+ + +
+
+
+ } + + @for (categoryOption of categoryOptions(); track categoryOption) { + @if (categoryOption !== 'all') { + + } + } + + + diff --git a/frontend/src/app/components/resources/resources.component.spec.ts b/frontend/src/app/components/resources/resources.component.spec.ts new file mode 100644 index 0000000..10d1039 --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourcesComponent } from './resources.component'; +import { ResourceService } from '../../services/resource.service'; +import { AuthService } from '../../services/auth.service'; +import { provideRouter } from '@angular/router'; + +describe('ResourcesComponent', () => { + let component: ResourcesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourcesComponent], + providers: [ + provideRouter([]), + { + provide: ResourceService, + useValue: { + getResourceCategories: () => Promise.resolve([]), + getResources: () => Promise.resolve({ + content: [], + page: 0, + size: 20, + totalElements: 0, + totalPages: 0, + hasNext: false, + }), + createResource: () => Promise.resolve({}), + uploadResourceFile: () => Promise.resolve({}), + deleteResource: () => Promise.resolve(), + getMyResources: () => Promise.resolve([]), + updateResource: () => Promise.resolve({}), + }, + }, + { + provide: AuthService, + useValue: { + isAuthenticated: () => false, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourcesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts new file mode 100644 index 0000000..21f4161 --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.ts @@ -0,0 +1,411 @@ +import { CommonModule } from '@angular/common'; +import { Component, HostListener, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NavigationEnd, Router, RouterLink } from '@angular/router'; +import { filter } from 'rxjs'; +import { AuthService } from '../../services/auth.service'; +import { CareerResource, ResourceQueryFilters, ResourceService } from '../../services/resource.service'; +import { LogoComponent } from '../ui/logo/logo.component'; + +const PAGE_SIZE = 20; +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; +type ResourceTypeFilter = 'all' | 'LINK' | 'FILE'; + +@Component({ + selector: 'app-resources', + standalone: true, + imports: [CommonModule, RouterLink, LogoComponent, FormsModule], + templateUrl: './resources.component.html', + styleUrl: './resources.component.css' +}) +export class ResourcesComponent { + authService = inject(AuthService); + private resourceService = inject(ResourceService); + private router = inject(Router); + + readonly resources = signal([]); + readonly isLoading = signal(true); + readonly isLoadingMore = signal(false); + readonly hasNext = signal(false); + readonly currentPage = signal(0); + readonly errorMessage = signal(''); + readonly saveMessage = signal(''); + readonly editMessage = signal(''); + readonly isSaving = signal(false); + readonly isUpdating = signal(false); + readonly isInAppRoute = signal(false); + readonly showAddResourceModal = signal(false); + readonly isLoadingMyResources = signal(false); + readonly myResources = signal([]); + readonly editingResource = signal(null); + private searchDebounceRef: ReturnType | null = null; + private scrollLoadDebounceRef: ReturnType | null = null; + + readonly searchQuery = signal(''); + readonly selectedCategoryFilter = signal(''); + readonly selectedTypeFilter = signal('all'); + readonly backendCategories = signal([]); + + title = ''; + url = ''; + category = ''; + description = ''; + contributionMode: 'link' | 'file' = 'link'; + selectedFile: File | null = null; + selectedFileError = ''; + + editTitle = ''; + editUrl = ''; + editCategory = ''; + editDescription = ''; + + readonly categoryOptions = computed(() => this.backendCategories()); + + onSearchQueryChange(value: string) { + this.searchQuery.set(value); + this.scheduleFilterReload(); + } + + onCategoryFilterChange(value: string) { + const normalized = value.trim(); + const lowered = normalized.toLowerCase(); + const shouldClear = !normalized || lowered === 'all' || lowered === 'all categories'; + this.selectedCategoryFilter.set(shouldClear ? '' : normalized); + this.loadResources(true); + } + + clearCategoryFilter() { + this.selectedCategoryFilter.set(''); + this.loadResources(true); + } + + onTypeFilterChange(value: ResourceTypeFilter) { + this.selectedTypeFilter.set(value); + this.loadResources(true); + } + + readonly groupedResources = computed(() => { + const grouped = new Map(); + + for (const resource of this.resources()) { + const key = resource.category?.trim() || 'General'; + const list = grouped.get(key) ?? []; + list.push(resource); + grouped.set(key, list); + } + + return Array.from(grouped.entries()).map(([category, links]) => ({ + category, + links + })); + }); + + constructor() { + this.syncRouteContext(); + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe(() => this.syncRouteContext()); + + this.loadCategoryOptions(); + this.loadResources(true); + if (this.authService.isAuthenticated()) { + this.loadMyResources(); + } + } + + private syncRouteContext() { + this.isInAppRoute.set(this.router.url.startsWith('/app/')); + } + + + async loadCategoryOptions(forceRefresh = false) { + try { + const categories = await this.resourceService.getResourceCategories(forceRefresh); + this.backendCategories.set(categories); + } catch { + this.backendCategories.set([]); + } + } + + openAddResourceModal() { + this.saveMessage.set(''); + this.showAddResourceModal.set(true); + } + + closeAddResourceModal() { + this.showAddResourceModal.set(false); + this.selectedFile = null; + this.selectedFileError = ''; + } + + async loadMyResources() { + if (!this.authService.isAuthenticated()) { + this.myResources.set([]); + return; + } + + this.isLoadingMyResources.set(true); + this.editMessage.set(''); + try { + const mine = await this.resourceService.getMyResources(); + this.myResources.set(mine); + } catch { + this.editMessage.set('Could not load your shared resources right now.'); + } finally { + this.isLoadingMyResources.set(false); + } + } + + async loadResources(reset = false) { + if (reset) { + this.currentPage.set(0); + this.resources.set([]); + this.isLoading.set(true); + } else { + this.isLoadingMore.set(true); + } + + this.errorMessage.set(''); + + try { + const page = this.currentPage(); + const response = await this.resourceService.getResources(page, PAGE_SIZE, this.currentFilters(), reset); + const merged = reset ? response.content : [...this.resources(), ...response.content]; + this.resources.set(merged); + this.hasNext.set(response.hasNext); + this.currentPage.set(page + 1); + } catch { + this.errorMessage.set('Could not load community resources right now. Please try again.'); + } finally { + this.isLoading.set(false); + this.isLoadingMore.set(false); + } + } + + @HostListener('window:scroll') + onWindowScroll() { + if (!this.hasNext() || this.isLoading() || this.isLoadingMore() || this.showAddResourceModal()) { + return; + } + + const scrollTop = window.scrollY || document.documentElement.scrollTop || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + const fullHeight = document.documentElement.scrollHeight || document.body.scrollHeight || 0; + const remaining = fullHeight - (scrollTop + viewportHeight); + + if (remaining > 220) { + return; + } + + if (this.scrollLoadDebounceRef) { + return; + } + + this.scrollLoadDebounceRef = setTimeout(() => { + this.scrollLoadDebounceRef = null; + }, 300); + + this.loadResources(); + } + + private scheduleFilterReload() { + if (this.searchDebounceRef) { + clearTimeout(this.searchDebounceRef); + } + + this.searchDebounceRef = setTimeout(() => { + this.loadResources(true); + }, 250); + } + + private currentFilters(): ResourceQueryFilters { + return { + query: this.searchQuery(), + category: this.selectedCategoryFilter(), + type: this.selectedTypeFilter() + }; + } + + onModeChange(mode: 'link' | 'file') { + this.contributionMode = mode; + this.saveMessage.set(''); + this.selectedFile = null; + this.selectedFileError = ''; + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] ?? null; + this.selectedFileError = ''; + + if (!file) { + this.selectedFile = null; + return; + } + + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'application/octet-stream' + ]; + + const validExtension = /\.(pdf|doc|docx|txt)$/i.test(file.name); + const validType = allowedTypes.includes(file.type) || file.type === ''; + + if (!validExtension || !validType) { + this.selectedFile = null; + this.selectedFileError = 'Only PDF, DOC, DOCX, and TXT files are allowed.'; + input.value = ''; + return; + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + this.selectedFile = null; + this.selectedFileError = 'File size must be 10MB or less.'; + input.value = ''; + return; + } + + this.selectedFile = file; + } + + async addResource() { + this.saveMessage.set(''); + + if (!this.title.trim() || !this.category.trim()) { + this.saveMessage.set('Title and category are required.'); + return; + } + + if (this.contributionMode === 'link' && !this.url.trim()) { + this.saveMessage.set('Resource URL is required for link contributions.'); + return; + } + + if (this.contributionMode === 'file' && !this.selectedFile) { + this.saveMessage.set('Please choose a file before uploading.'); + return; + } + + this.isSaving.set(true); + + try { + let created: CareerResource; + + if (this.contributionMode === 'file' && this.selectedFile) { + created = await this.resourceService.uploadResourceFile({ + title: this.title, + category: this.category, + description: this.description, + file: this.selectedFile + }); + } else { + created = await this.resourceService.createResource({ + title: this.title, + url: this.url, + category: this.category, + description: this.description + }); + } + + this.resources.set([created, ...this.resources()]); + this.hasNext.set(true); + this.title = ''; + this.url = ''; + this.category = ''; + this.description = ''; + this.selectedFile = null; + this.saveMessage.set('Resource added. Thanks for contributing!'); + this.showAddResourceModal.set(false); + await this.loadCategoryOptions(true); + if (created.ownedByCurrentUser) { + this.myResources.set([created, ...this.myResources()]); + } + } catch { + this.saveMessage.set('Could not add the resource. Please verify your details and try again.'); + } finally { + this.isSaving.set(false); + } + } + + async deleteResource(resource: CareerResource) { + this.saveMessage.set(''); + this.editMessage.set(''); + try { + await this.resourceService.deleteResource(resource.id); + this.resources.set(this.resources().filter(item => item.id !== resource.id)); + this.myResources.set(this.myResources().filter(item => item.id !== resource.id)); + this.saveMessage.set('Resource removed successfully.'); + await this.loadCategoryOptions(true); + } catch { + this.saveMessage.set('Could not remove this resource right now.'); + } + } + + startEditResource(resource: CareerResource) { + this.editingResource.set(resource); + this.editTitle = resource.title; + this.editUrl = resource.resourceType === 'LINK' ? resource.url : ''; + this.editCategory = resource.category; + this.editDescription = resource.description ?? ''; + this.editMessage.set(''); + } + + cancelEditResource() { + this.editingResource.set(null); + this.editMessage.set(''); + } + + async saveResourceEdit() { + const target = this.editingResource(); + if (!target) { + return; + } + + if (!this.editTitle.trim() || !this.editCategory.trim()) { + this.editMessage.set('Title and category are required.'); + return; + } + + if (target.resourceType === 'LINK' && !this.editUrl.trim()) { + this.editMessage.set('URL is required for link resources.'); + return; + } + + this.isUpdating.set(true); + this.editMessage.set(''); + + try { + const updated = await this.resourceService.updateResource(target.id, { + title: this.editTitle, + url: target.resourceType === 'LINK' ? this.editUrl : undefined, + category: this.editCategory, + description: this.editDescription + }); + + this.myResources.set(this.myResources().map(item => item.id === updated.id ? updated : item)); + this.resources.set(this.resources().map(item => item.id === updated.id ? updated : item)); + this.editingResource.set(null); + this.saveMessage.set('Resource updated successfully.'); + await this.loadCategoryOptions(true); + } catch { + this.editMessage.set('Could not update this resource right now.'); + } finally { + this.isUpdating.set(false); + } + } + + formatFileSize(size?: number) { + if (!size) { + return ''; + } + + if (size < 1024 * 1024) { + return `${Math.ceil(size / 1024)} KB`; + } + + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } +} diff --git a/frontend/src/app/services/resource.service.ts b/frontend/src/app/services/resource.service.ts new file mode 100644 index 0000000..c797792 --- /dev/null +++ b/frontend/src/app/services/resource.service.ts @@ -0,0 +1,147 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { AuthService } from './auth.service'; + +export interface CareerResource { + id: string; + title: string; + url: string; + category: string; + description?: string; + resourceType: 'LINK' | 'FILE'; + originalFileName?: string; + fileSizeBytes?: number; + ownedByCurrentUser: boolean; + submittedByName: string; + createdAt: string; +} + +export interface CareerResourcePage { + content: CareerResource[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; +} + +export interface CreateResourcePayload { + title: string; + url: string; + category: string; + description?: string; +} + +export interface UpdateResourcePayload { + title: string; + url?: string; + category: string; + description?: string; +} + +export interface ResourceQueryFilters { + query?: string; + category?: string; + type?: 'all' | 'LINK' | 'FILE'; +} + +@Injectable({ providedIn: 'root' }) +export class ResourceService { + private readonly API = environment.apiBaseUrl; + private http = inject(HttpClient); + private authService = inject(AuthService); + private apiUrl = `${this.API}/api/resources`; + private pageCache = new Map(); + private categoryCache: string[] | null = null; + + async getResources(page: number, size: number, filters: ResourceQueryFilters = {}, forceRefresh = false) { + const cacheScope = this.authService.currentUser()?.email ?? 'anonymous'; + const query = filters.query?.trim() ?? ''; + const category = filters.category?.trim() ?? ''; + const type = filters.type && filters.type !== 'all' ? filters.type : ''; + const key = `${cacheScope}:${page}:${size}:${query}:${category}:${type}`; + + if (!forceRefresh && this.pageCache.has(key)) { + return this.pageCache.get(key)!; + } + + const data = await firstValueFrom( + this.http.get(this.apiUrl, { + params: { + page, + size, + ...(query ? { query } : {}), + ...(category ? { category } : {}), + ...(type ? { type } : {}) + } + }) + ); + + this.pageCache.set(key, data); + return data; + } + + invalidateCache() { + this.pageCache.clear(); + this.categoryCache = null; + } + + + async getResourceCategories(forceRefresh = false) { + if (!forceRefresh && this.categoryCache) { + return this.categoryCache; + } + + const categories = await firstValueFrom(this.http.get(`${this.apiUrl}/categories`)); + this.categoryCache = categories; + return categories; + } + + async createResource(payload: CreateResourcePayload) { + const created = await firstValueFrom(this.http.post(this.apiUrl, payload)); + this.invalidateCache(); + return created; + } + + async uploadResourceFile(payload: { + title: string; + category: string; + description?: string; + file: File; + }) { + const formData = new FormData(); + formData.append('title', payload.title); + formData.append('category', payload.category); + if (payload.description?.trim()) { + formData.append('description', payload.description.trim()); + } + formData.append('file', payload.file); + + const created = await firstValueFrom( + this.http.post(`${this.apiUrl}/upload`, formData) + ); + + this.invalidateCache(); + return created; + } + + async deleteResource(resourceId: string) { + await firstValueFrom(this.http.delete(`${this.apiUrl}/${resourceId}`)); + this.invalidateCache(); + } + + async getMyResources() { + return await firstValueFrom(this.http.get(`${this.apiUrl}/mine`)); + } + + async updateResource(resourceId: string, payload: UpdateResourcePayload) { + const updated = await firstValueFrom( + this.http.put(`${this.apiUrl}/${resourceId}`, payload) + ); + + this.invalidateCache(); + return updated; + } +}