diff --git a/client/src/components/TopicsTable/TopicsTable.tsx b/client/src/components/TopicsTable/TopicsTable.tsx index 5d17fccac..22c7ec670 100644 --- a/client/src/components/TopicsTable/TopicsTable.tsx +++ b/client/src/components/TopicsTable/TopicsTable.tsx @@ -44,6 +44,8 @@ const TopicsTable = (props: ITopicOverviewsTableProps) => { return 'red' case TopicState.DRAFT: return 'yellow' + case TopicState.EXPIRED: + return 'orange' default: return 'gray' } diff --git a/client/src/pages/LandingPage/LandingPage.tsx b/client/src/pages/LandingPage/LandingPage.tsx index 7fe23dbd7..ef7d54f56 100644 --- a/client/src/pages/LandingPage/LandingPage.tsx +++ b/client/src/pages/LandingPage/LandingPage.tsx @@ -182,7 +182,7 @@ const LandingPage = () => { justify='center' onClick={(e) => e.stopPropagation()} > - {topic.state !== TopicState.CLOSED && ( + {topic.state === TopicState.OPEN && ( - ) : 'applicationDeadline' in topic && - topic.applicationDeadline && - new Date(topic.applicationDeadline) < new Date() ? ( - - ) : ( + ) : 'state' in topic && topic.state === TopicState.OPEN ? ( + ) : ( + )} ) diff --git a/client/src/pages/ReplaceApplicationPage/components/SelectTopicStep/components/CollapsibleTopicElement.tsx b/client/src/pages/ReplaceApplicationPage/components/SelectTopicStep/components/CollapsibleTopicElement.tsx index 7412d1f56..c6e1527f8 100644 --- a/client/src/pages/ReplaceApplicationPage/components/SelectTopicStep/components/CollapsibleTopicElement.tsx +++ b/client/src/pages/ReplaceApplicationPage/components/SelectTopicStep/components/CollapsibleTopicElement.tsx @@ -12,6 +12,7 @@ import { Loader, } from '@mantine/core' import type { ITopicOverview, ITopic } from '../../../../../requests/responses/topic' +import { TopicState } from '../../../../../requests/responses/topic' import ThesisTypeBadge from '../../../../LandingPage/components/ThesisTypBadge/ThesisTypBadge' import type { IPublishedThesis } from '../../../../../requests/responses/thesis' import { useHover } from '@mantine/hooks' @@ -34,10 +35,7 @@ const CollapsibleTopicElement = ({ topic, onApply }: ICollapsibleTopicElementPro const isTopicOverview = 'topicId' in topic const fullTopic = useTopic(isTopicOverview && expanded ? topic.topicId : undefined) - const deadlinePassed = - !!fullTopic && - !!fullTopic.applicationDeadline && - new Date(fullTopic.applicationDeadline) < new Date() + const canApply = !!fullTopic && fullTopic.state === TopicState.OPEN return ( onApply(fullTopic || undefined)} fullWidth - disabled={!fullTopic || deadlinePassed} + disabled={!canApply} > Apply - {deadlinePassed && ( + {!canApply && !!fullTopic && ( - Application deadline has passed. + Applications are not open for this topic. )} diff --git a/client/src/pages/TopicPage/TopicPage.tsx b/client/src/pages/TopicPage/TopicPage.tsx index 324e5f670..f49d8cd2a 100644 --- a/client/src/pages/TopicPage/TopicPage.tsx +++ b/client/src/pages/TopicPage/TopicPage.tsx @@ -11,6 +11,7 @@ import ApplicationsTable from '../../components/ApplicationsTable/ApplicationsTa import { NotePencil } from '@phosphor-icons/react' import TopicAdittionalInformationCard from './components/TopicAdittionalInformationCard' import TopicInformationCard from './components/TopicInformationCard' +import { TopicState } from '../../requests/responses/topic' const TopicPage = () => { const { topicId } = useParams<{ topicId: string }>() @@ -42,8 +43,7 @@ const TopicPage = () => { return isExaminer || isSupervisor } - const deadlinePassed = - !!topic.applicationDeadline && new Date(topic.applicationDeadline) < new Date() + const canApply = topic.state === TopicState.OPEN return ( @@ -51,11 +51,7 @@ const TopicPage = () => { {topic.title} {!managementAccess && !checkIfUserIsExaminerOrSupervisor() && ( - {deadlinePassed ? ( - - ) : ( + {canApply ? ( + ) : ( + )} - {deadlinePassed && ( + {!canApply && ( - Application deadline has passed. + Applications are not open for this topic. )} diff --git a/client/src/requests/responses/topic.ts b/client/src/requests/responses/topic.ts index 6dac20708..eb2d01536 100644 --- a/client/src/requests/responses/topic.ts +++ b/client/src/requests/responses/topic.ts @@ -5,6 +5,7 @@ export enum TopicState { OPEN = 'OPEN', DRAFT = 'DRAFT', CLOSED = 'CLOSED', + EXPIRED = 'EXPIRED', } export interface ITopicOverview { diff --git a/client/src/utils/format.ts b/client/src/utils/format.ts index 00c4cc91a..e44818d33 100644 --- a/client/src/utils/format.ts +++ b/client/src/utils/format.ts @@ -130,6 +130,7 @@ export function formatTopicState(state: TopicState) { [TopicState.OPEN]: 'Open', [TopicState.DRAFT]: 'Draft', [TopicState.CLOSED]: 'Closed', + [TopicState.EXPIRED]: 'Expired', } return stateMap[state] diff --git a/server/src/main/java/de/tum/cit/aet/thesis/constants/TopicState.java b/server/src/main/java/de/tum/cit/aet/thesis/constants/TopicState.java index 450ed09c7..3c892abcd 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/constants/TopicState.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/constants/TopicState.java @@ -8,7 +8,8 @@ public enum TopicState { OPEN("OPEN"), CLOSED("WRITING"), - DRAFT("DRAFT"); + DRAFT("DRAFT"), + EXPIRED("EXPIRED"); private final String value; } diff --git a/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java b/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java index 9da8d8f79..48b051246 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java @@ -62,8 +62,8 @@ public void rejectOldApplications() { for (ResearchGroupSettings settings : enabledResearchGroups) { Map> reminderApplicationsByUser = new HashMap<>(); - List openTopics = topicService.getOpenFromResearchGroup(settings.getResearchGroupId()); - for (Topic topic : openTopics) { + List publishedTopics = topicService.getPublishedFromResearchGroup(settings.getResearchGroupId()); + for (Topic topic : publishedTopics) { Instant referenceDate; if (topic.getApplicationDeadline() != null) { diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/Topic.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/Topic.java index d01e80a02..bb78964b4 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/entity/Topic.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/Topic.java @@ -129,6 +129,8 @@ public TopicState getTopicState() { return TopicState.CLOSED; } else if (this.publishedAt == null) { return TopicState.DRAFT; + } else if (this.applicationDeadline != null && this.applicationDeadline.isBefore(Instant.now())) { + return TopicState.EXPIRED; } else { return TopicState.OPEN; } diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRepository.java index 86f6357a9..e368df12e 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRepository.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -20,9 +21,10 @@ public interface TopicRepository extends JpaRepository { ( CAST(:states AS TEXT[]) IS NULL OR ( - ('CLOSED' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NOT NULL) - OR ('DRAFT' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NULL AND t.published_at IS NULL) - OR ('OPEN' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NULL AND t.published_at IS NOT NULL) + ('CLOSED' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NOT NULL) + OR ('DRAFT' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NULL AND t.published_at IS NULL) + OR ('OPEN' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NULL AND t.published_at IS NOT NULL AND (t.application_deadline IS NULL OR t.application_deadline >= NOW())) + OR ('EXPIRED' = ANY(CAST(:states AS TEXT[])) AND t.closed_at IS NULL AND t.published_at IS NOT NULL AND t.application_deadline IS NOT NULL AND t.application_deadline < NOW()) ) ) """, nativeQuery = true) @@ -37,6 +39,8 @@ Page searchTopics( @Query("SELECT DISTINCT t FROM Topic t " + "WHERE (:searchQuery IS NULL OR LOWER(t.title) LIKE :searchQuery) " + "AND t.closedAt IS NULL " + + "AND t.publishedAt IS NOT NULL " + + "AND (t.applicationDeadline IS NULL OR t.applicationDeadline >= CURRENT_TIMESTAMP) " + "AND ( :userId IS NULL " + " OR ( :excludeSupervised = true " + " AND EXISTS (SELECT 1 FROM TopicRole r " + @@ -57,7 +61,18 @@ Page findOpenTopicsForUserByRoles( SELECT COUNT(*) FROM Topic t WHERE t.closedAt IS NULL + AND t.publishedAt IS NOT NULL + AND (t.applicationDeadline IS NULL OR t.applicationDeadline >= CURRENT_TIMESTAMP) AND (:researchGroupId IS NULL OR t.researchGroup.id = :researchGroupId) """) long countOpenTopics(@Param("researchGroupId") UUID researchGroupId); + + @Query(""" + SELECT t FROM Topic t + WHERE t.closedAt IS NULL AND t.publishedAt IS NOT NULL + AND (:researchGroupId IS NULL OR t.researchGroup.id = :researchGroupId) + """) + List findPublishedTopics( + @Param("researchGroupId") UUID researchGroupId + ); } diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java index 9fd403259..8fc4440fb 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java @@ -203,6 +203,10 @@ public Application createApplication(User user, UUID researchGroupId, UUID topic Topic topic = topicId == null ? null : topicService.findById(topicId); Instant now = Instant.now(clock); + if (topic != null && topic.getPublishedAt() == null) { + throw new ResourceInvalidParametersException("This topic is not open for applications."); + } + if (topic != null && topic.getClosedAt() != null) { throw new ResourceInvalidParametersException("This topic is already closed. You cannot submit new applications for it."); } diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java index 3384fe444..8a06f51a3 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java @@ -111,8 +111,12 @@ public Page getAll( String[] typesFilter = types == null || types.length == 0 ? null : types; - if (states != null && Arrays.stream(states).anyMatch(s -> s.equals(TopicState.CLOSED.name()) || s.equals(TopicState.DRAFT.name()))) { - currentUserProvider().assertCanAccessResearchGroup(researchGroup); + if (states != null && Arrays.stream(states).anyMatch(s -> s.equals(TopicState.CLOSED.name()) || s.equals(TopicState.DRAFT.name()) || s.equals(TopicState.EXPIRED.name()))) { + CurrentUserProvider cup = currentUserProvider(); + if (!cup.isAdmin() && !cup.getUser().hasAnyGroup("supervisor", "advisor")) { + throw new org.springframework.security.access.AccessDeniedException("Only privileged users can query non-OPEN topic states."); + } + cup.assertCanAccessResearchGroup(researchGroup); } String[] statesFilter = (states != null && states.length > 0) ? states : new String[] { TopicState.OPEN.name() }; @@ -178,6 +182,10 @@ public List getOpenFromResearchGroup(UUID researchGroupId) { ).toList(); } + public List getPublishedFromResearchGroup(UUID researchGroupId) { + return topicRepository.findPublishedTopics(researchGroupId); + } + // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems @Transactional public Topic createTopic( diff --git a/server/src/test/java/de/tum/cit/aet/thesis/mock/EntityMockFactory.java b/server/src/test/java/de/tum/cit/aet/thesis/mock/EntityMockFactory.java index 0041f2020..52092e488 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/mock/EntityMockFactory.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/mock/EntityMockFactory.java @@ -13,6 +13,7 @@ import de.tum.cit.aet.thesis.entity.key.ThesisRoleId; import de.tum.cit.aet.thesis.entity.key.UserGroupId; +import java.time.Instant; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -92,6 +93,9 @@ public static Topic createTopic(String title, ResearchGroup researchGroup) { topic.setTitle(title); topic.setRoles(new ArrayList<>()); topic.setResearchGroup(researchGroup); + topic.setCreatedAt(Instant.now()); + topic.setUpdatedAt(Instant.now()); + topic.setPublishedAt(Instant.now()); return topic; }