From c5f90d9f860ac1c649cf0fd9491a15cd7cef6b9b Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 22:35:01 +0530 Subject: [PATCH 01/11] Add cached paginated resources with file upload/delete support --- .../controller/CareerResourceController.java | 49 +++++- .../jobtrackerpro/dto/CareerResourceDTO.java | 4 + .../dto/CareerResourcePageResponse.java | 17 +++ .../jobtrackerpro/entity/CareerResource.java | 8 + .../repo/CareerResourceRepository.java | 4 + .../service/CareerResourceService.java | 127 +++++++++++++--- .../resources/resources.component.html | 117 ++++++++++----- .../resources/resources.component.spec.ts | 11 +- .../resources/resources.component.ts | 140 ++++++++++++++++-- frontend/src/app/services/resource.service.ts | 69 ++++++++- 10 files changed, 471 insertions(+), 75 deletions(-) create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java index b40c592..6605d05 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -1,17 +1,25 @@ 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.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.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.List; +import java.util.UUID; @RestController @RequestMapping("/api/resources") @@ -24,8 +32,11 @@ public CareerResourceController(CareerResourceService careerResourceService) { } @GetMapping - public ResponseEntity> getResources() { - return ResponseEntity.ok(careerResourceService.getAllResources()); + public ResponseEntity getResources( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(careerResourceService.getResourcePage(page, size, getAuthenticatedEmailOrNull())); } @PostMapping @@ -34,7 +45,39 @@ public ResponseEntity addResource(@RequestBody CreateCareerRe return ResponseEntity.ok(careerResourceService.createResource(email, 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 index f29ed91..91147a1 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java @@ -12,6 +12,10 @@ public class CareerResourceDTO { 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/entity/CareerResource.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java index 5e65f36..0472131 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java @@ -28,12 +28,20 @@ public class CareerResource { @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; diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java index 828835e..ee4935c 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java @@ -2,6 +2,8 @@ import com.thughari.jobtrackerpro.entity.CareerResource; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.UUID; @@ -9,5 +11,7 @@ public interface CareerResourceRepository extends JpaRepository { List findAllByOrderByCreatedAtDesc(); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + 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 index 669e826..52f9e66 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java @@ -1,53 +1,123 @@ 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.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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import java.util.List; +import org.springframework.web.multipart.MultipartFile; @Service @Transactional public class CareerResourceService { + private static final int MAX_PAGE_SIZE = 50; + private final CareerResourceRepository resourceRepository; private final UserRepository userRepository; + private final StorageService storageService; - public CareerResourceService(CareerResourceRepository resourceRepository, UserRepository userRepository) { + public CareerResourceService(CareerResourceRepository resourceRepository, + UserRepository userRepository, + StorageService storageService) { this.resourceRepository = resourceRepository; this.userRepository = userRepository; + this.storageService = storageService; } @Transactional(readOnly = true) - public List getAllResources() { - return resourceRepository.findAllByOrderByCreatedAtDesc() + @Cacheable(value = "resourcePages", key = "{#page, #size, #viewerEmail == null ? 'anon' : #viewerEmail}") + public CareerResourcePageResponse getResourcePage(int page, int size, String viewerEmail) { + int sanitizedPage = Math.max(0, page); + int sanitizedSize = Math.max(1, Math.min(size, MAX_PAGE_SIZE)); + + var pageable = PageRequest.of(sanitizedPage, sanitizedSize, Sort.by(Sort.Direction.DESC, "createdAt")); + var resourcePage = resourceRepository.findAllByOrderByCreatedAtDesc(pageable); + + var content = resourcePage.getContent() .stream() - .map(this::toDTO) + .map(resource -> toDTO(resource, viewerEmail)) .toList(); + + return new CareerResourcePageResponse( + content, + resourcePage.getNumber(), + resourcePage.getSize(), + resourcePage.getTotalElements(), + resourcePage.getTotalPages(), + resourcePage.hasNext() + ); } + @CacheEvict(value = "resourcePages", allEntries = true) public CareerResourceDTO createResource(String email, CreateCareerResourceRequest request) { String normalizedUrl = normalizeUrl(request.getUrl()); - validatePayload(request, normalizedUrl); + validateLinkPayload(request, normalizedUrl); - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("User not found")); + 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.setSubmittedByEmail(user.getEmail()); - resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); + 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()); + } - return toDTO(resourceRepository.save(resource)); + resourceRepository.delete(resource); } @PostConstruct @@ -73,21 +143,36 @@ private void addSeedResource(String title, String url, String category) { 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 void validatePayload(CreateCareerResourceRequest request, String normalizedUrl) { - if (request.getTitle() == null || request.getTitle().isBlank()) { + 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 (request.getCategory() == null || request.getCategory().isBlank()) { + if (category == null || category.isBlank()) { throw new IllegalArgumentException("Category is required"); } - if (normalizedUrl == null || normalizedUrl.isBlank()) { - throw new IllegalArgumentException("Valid URL is required"); - } } private String normalizeUrl(String url) { @@ -101,13 +186,17 @@ private String normalizeUrl(String url) { return trimmed; } - private CareerResourceDTO toDTO(CareerResource resource) { + 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/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index a78f40e..32f0c9c 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -1,22 +1,22 @@
- } -
+

Community Career Resource Mine

Smart, cached resources built for scale

Resources are cached and paginated so the app stays fast even with large catalogs. Logged-in users can share links, upload documents, and remove their own contributions.

- @if (authService.isAuthenticated()) { -
-

Share a resource

-

Mobile-first and simple: add a link or upload a file (max 10MB).

- -
- - +
+
+
+

Search & filter resources

+

Find links and files quickly by keyword, category, or type.

-
- - -
+ @if (authService.isAuthenticated()) { + + } +
- @if (contributionMode === 'link') { - - } @else { -
- } @else { + @if (saveMessage()) { +

{{ saveMessage() }}

+ } +
+ + @if (!authService.isAuthenticated()) {
Want to contribute? Log in and add links or files.
@@ -97,6 +98,10 @@

Share a resource

@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) { @@ -147,4 +152,69 @@

{{ section.category }}< } }

+ + @if (showAddResourceModal()) { +
+
+
+
+

Add a resource

+

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

+
+ +
+ +
+ + +
+ +
+ + +
+ + @if (contributionMode === 'link') { + + } @else { + + } + + + +
+ + +
+
+
+ }
diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts index 8870b36..3ac42b1 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -2,14 +2,16 @@ import { CommonModule } from '@angular/common'; import { Component, 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, ResourceService } from '../../services/resource.service'; import { LogoComponent } from '../ui/logo/logo.component'; -import { filter } from 'rxjs'; const PAGE_SIZE = 20; const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; +type ResourceTypeFilter = 'all' | 'LINK' | 'FILE'; + @Component({ selector: 'app-resources', standalone: true, @@ -31,6 +33,11 @@ export class ResourcesComponent { readonly saveMessage = signal(''); readonly isSaving = signal(false); readonly isInAppRoute = signal(false); + readonly showAddResourceModal = signal(false); + + searchQuery = ''; + selectedCategoryFilter = 'all'; + selectedTypeFilter: ResourceTypeFilter = 'all'; title = ''; url = ''; @@ -40,10 +47,37 @@ export class ResourcesComponent { selectedFile: File | null = null; selectedFileError = ''; + readonly categoryOptions = computed(() => { + const categories = new Set(); + for (const resource of this.resources()) { + categories.add(resource.category?.trim() || 'General'); + } + return ['all', ...Array.from(categories).sort((a, b) => a.localeCompare(b))]; + }); + + readonly filteredResources = computed(() => { + const normalizedQuery = this.searchQuery.trim().toLowerCase(); + + return this.resources().filter((resource) => { + const matchesQuery = + !normalizedQuery || + resource.title.toLowerCase().includes(normalizedQuery) || + resource.category.toLowerCase().includes(normalizedQuery) || + (resource.description || '').toLowerCase().includes(normalizedQuery) || + resource.submittedByName.toLowerCase().includes(normalizedQuery); + + const resourceCategory = resource.category?.trim() || 'General'; + const matchesCategory = this.selectedCategoryFilter === 'all' || resourceCategory === this.selectedCategoryFilter; + const matchesType = this.selectedTypeFilter === 'all' || resource.resourceType === this.selectedTypeFilter; + + return matchesQuery && matchesCategory && matchesType; + }); + }); + readonly groupedResources = computed(() => { const grouped = new Map(); - for (const resource of this.resources()) { + for (const resource of this.filteredResources()) { const key = resource.category?.trim() || 'General'; const list = grouped.get(key) ?? []; list.push(resource); @@ -69,6 +103,17 @@ export class ResourcesComponent { this.isInAppRoute.set(this.router.url.startsWith('/app/')); } + openAddResourceModal() { + this.saveMessage.set(''); + this.showAddResourceModal.set(true); + } + + closeAddResourceModal() { + this.showAddResourceModal.set(false); + this.selectedFile = null; + this.selectedFileError = ''; + } + async loadResources(reset = false) { if (reset) { this.currentPage.set(0); @@ -187,6 +232,7 @@ export class ResourcesComponent { this.description = ''; this.selectedFile = null; this.saveMessage.set('Resource added. Thanks for contributing!'); + this.showAddResourceModal.set(false); } catch { this.saveMessage.set('Could not add the resource. Please verify your details and try again.'); } finally { From bf4e676fc8db1c019f61a59c76ac10b038290faa Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 23:08:27 +0530 Subject: [PATCH 04/11] Fix resources search/filter reactivity with signal-backed filters --- .../resources/resources.component.html | 6 ++--- .../resources/resources.component.ts | 26 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index 54757c4..e73a9a8 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -55,14 +55,14 @@

Search & filter resources

Search resources
-
+ @for (categoryOption of categoryOptions(); track categoryOption) { } - +