diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index d36adcf8..73522636 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,4 +1,4 @@ -name: Modtale CI/CD +name: Modtale CI/CD on: push: @@ -51,7 +51,7 @@ jobs: fi echo "GIT_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV echo "BRANCH_SLUG=$BRANCH_SLUG" >> $GITHUB_ENV - + if [ "$BRANCH_NAME" = "main" ]; then echo "ENV_TYPE=prod" >> $GITHUB_ENV echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV @@ -76,7 +76,7 @@ jobs: echo "WARDEN_ENABLED=true" >> $GITHUB_ENV echo "OAUTH_ENABLED=true" >> $GITHUB_ENV echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV - + elif [ "$BRANCH_NAME" = "develop" ]; then echo "ENV_TYPE=dev" >> $GITHUB_ENV echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV @@ -101,7 +101,7 @@ jobs: echo "WARDEN_ENABLED=true" >> $GITHUB_ENV echo "OAUTH_ENABLED=true" >> $GITHUB_ENV echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV - + else SLUG="$BRANCH_SLUG" BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME }}" @@ -417,7 +417,7 @@ jobs: --config=cloudbuild.yml \ --service-account="projects/$PROJECT_ID/serviceAccounts/$BUILD_SERVICE_ACCOUNT" \ --substitutions=_TAG=${{ env.TAG }} . - + ARGS=( "--image" "gcr.io/$PROJECT_ID/modtale-backend:${{ env.TAG }}" "--region" "$REGION" @@ -602,7 +602,7 @@ jobs: else PUBLIC_BACKEND_URL=$B_URL fi - + gcloud run services update ${{ env.BACKEND_SERVICE }} \ --region $REGION \ --update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL" @@ -674,7 +674,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY - + if [ -n "$PUBLIC_API_URL" ]; then echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY elif [ -n "$B_URL" ]; then @@ -682,13 +682,13 @@ jobs: else echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + if [ -n "$F_URL" ]; then echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY else echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY diff --git a/backend/src/main/java/net/modtale/ModtaleApplication.java b/backend/src/main/java/net/modtale/ModtaleApplication.java index 4313660d..05d507f3 100644 --- a/backend/src/main/java/net/modtale/ModtaleApplication.java +++ b/backend/src/main/java/net/modtale/ModtaleApplication.java @@ -8,6 +8,7 @@ @SpringBootApplication @ConfigurationPropertiesScan +@EnableCaching @EnableMethodSecurity public class ModtaleApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 187e00b1..dd55da99 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -289,7 +289,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/sitemap.xml", "/actuator/health", "/actuator/health/**").permitAll() .requestMatchers("/client-metadata.json").permitAll() .requestMatchers(HttpMethod.GET, + "/api/v1/projects", "/api/v1/projects/**", + "/api/v1/modjams", + "/api/v1/modjams/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", @@ -361,9 +364,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/auth/change-password", "/api/v1/auth/credentials" ).authenticated() - .requestMatchers(HttpMethod.POST, "/api/v1/projects/**").authenticated() - .requestMatchers(HttpMethod.PUT, "/api/v1/projects/**").authenticated() - .requestMatchers(HttpMethod.DELETE, "/api/v1/projects/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v1/projects", "/api/v1/projects/**", "/api/v1/modjams", "/api/v1/modjams/**").authenticated() + .requestMatchers(HttpMethod.PUT, "/api/v1/projects", "/api/v1/projects/**", "/api/v1/modjams", "/api/v1/modjams/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/api/v1/projects", "/api/v1/projects/**", "/api/v1/modjams", "/api/v1/modjams/**").authenticated() + .requestMatchers(HttpMethod.PATCH, "/api/v1/modjams", "/api/v1/modjams/**").authenticated() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/net/modtale/controller/ModjamController.java b/backend/src/main/java/net/modtale/controller/ModjamController.java new file mode 100644 index 00000000..38abcf1f --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/ModjamController.java @@ -0,0 +1,166 @@ +package net.modtale.controller; + +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.model.user.User; +import net.modtale.service.ModjamService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/modjams") +public class ModjamController { + + @Autowired private ModjamService modjamService; + @Autowired private AccountService accountService; + + @GetMapping + public ResponseEntity> getAllJams() { + return ResponseEntity.ok(modjamService.getAllJams()); + } + + @GetMapping("/user/me") + public ResponseEntity> getMyJams() { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.getUserHostedJams(user.getId())); + } + + @GetMapping("/{slug}") + public ResponseEntity getJamBySlug(@PathVariable String slug) { + return ResponseEntity.ok(modjamService.getJamBySlug(slug)); + } + + @PostMapping + public ResponseEntity createJam(@RequestBody Modjam jam) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.createJam(jam, user.getId(), user.getUsername())); + } + + @PutMapping("/{id}") + public ResponseEntity updateJam(@PathVariable String id, @RequestBody Modjam jam) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.updateJam(id, jam)); + } + + @PutMapping("/{id}/icon") + public ResponseEntity updateIcon(@PathVariable String id, @RequestParam("file") MultipartFile file) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { + modjamService.updateIcon(id, file); + return ResponseEntity.ok().build(); + } catch (Exception e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/{id}/banner") + public ResponseEntity updateBanner(@PathVariable String id, @RequestParam("file") MultipartFile file) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { + modjamService.updateBanner(id, file); + return ResponseEntity.ok().build(); + } catch (Exception e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteJam(@PathVariable String id) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { + modjamService.deleteJam(id, user.getId()); + return ResponseEntity.ok().build(); + } catch (ResponseStatusException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getReason()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/{jamId}/submissions") + public ResponseEntity> getSubmissions(@PathVariable String jamId) { + return ResponseEntity.ok(modjamService.getSubmissions(jamId)); + } + + @PostMapping("/{jamId}/participate") + public ResponseEntity participate(@PathVariable String jamId) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.participate(jamId, user.getId())); + } + + @PostMapping("/{jamId}/leave") + public ResponseEntity leaveJam(@PathVariable String jamId) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.leaveJam(jamId, user.getId())); + } + + @PostMapping("/{jamId}/submit") + public ResponseEntity submitProject(@PathVariable String jamId, @RequestBody Map body) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.submitProject(jamId, body.get("projectId"), user.getId())); + } + + @PostMapping("/{jamId}/vote") + public ResponseEntity vote(@PathVariable String jamId, @RequestBody Map body) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + + String submissionId = (String) body.get("submissionId"); + String categoryId = (String) body.get("categoryId"); + int score = (Integer) body.get("score"); + + return ResponseEntity.ok(modjamService.vote(jamId, submissionId, categoryId, score, user.getId())); + } + + @PostMapping("/{jamId}/finalize") + public ResponseEntity finalizeJam(@PathVariable String jamId, @RequestBody List> winners) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.finalizeJam(jamId, user.getId(), winners)); + } + + // Judging Endpoints + @PostMapping("/{jamId}/judges/invite") + public ResponseEntity inviteJudge(@PathVariable String jamId, @RequestBody Map body) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.inviteJudge(jamId, body.get("username"), user.getId())); + } + + @PostMapping("/{jamId}/judges/accept") + public ResponseEntity acceptJudge(@PathVariable String jamId) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.acceptJudgeInvite(jamId, user.getId(), user.getUsername())); + } + + @PostMapping("/{jamId}/judges/decline") + public ResponseEntity declineJudge(@PathVariable String jamId) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.declineJudgeInvite(jamId, user.getUsername())); + } + + @DeleteMapping("/{jamId}/judges/{username}") + public ResponseEntity removeJudge(@PathVariable String jamId, @PathVariable String username) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.removeJudge(jamId, username, user.getId())); + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java index a5e0e063..b73f7b4a 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java @@ -36,6 +36,7 @@ public class ProjectDTO { private List types; private List childProjectIds; private List modIds; + private List modjamIds; private boolean allowModpacks; private boolean allowComments; private boolean hmWikiEnabled; @@ -109,6 +110,8 @@ public class ProjectDTO { public void setChildProjectIds(List childProjectIds) { this.childProjectIds = childProjectIds; } public List getModIds() { return modIds; } public void setModIds(List modIds) { this.modIds = modIds; } + public List getModjamIds() { return modjamIds; } + public void setModjamIds(List modjamIds) { this.modjamIds = modjamIds; } public boolean isAllowModpacks() { return allowModpacks; } public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } diff --git a/backend/src/main/java/net/modtale/model/jam/Modjam.java b/backend/src/main/java/net/modtale/model/jam/Modjam.java new file mode 100644 index 00000000..352116dd --- /dev/null +++ b/backend/src/main/java/net/modtale/model/jam/Modjam.java @@ -0,0 +1,179 @@ +package net.modtale.model.jam; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Document(collection = "modjams") +public class Modjam { + + @Id + private String id; + private String slug; + private String title; + private String description; + private String rules; + + private String imageUrl; + private String bannerUrl; + + private String hostId; + private String hostName; + + private Instant startDate; + private Instant endDate; + private Instant votingEndDate; + + private String status = "DRAFT"; + + private List participantIds = new ArrayList<>(); + private List judgeIds = new ArrayList<>(); + private List pendingJudgeInvites = new ArrayList<>(); + + @Transient + private List> judgeProfiles = new ArrayList<>(); + + public static class Category { + private String id; + private String name; + private String description; + private int maxScore; + + public Category() {} + + public Category(String id, String name, String description, int maxScore) { + this.id = id; + this.name = name; + this.description = description; + this.maxScore = maxScore; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public int getMaxScore() { return maxScore; } + public void setMaxScore(int maxScore) { this.maxScore = maxScore; } + } + + public static class Restrictions { + private boolean requireNewProject; + private boolean requireSourceRepo; + private boolean requireOsiLicense; + private Integer minContributors; + private Integer maxContributors; + private boolean requireUniqueSubmission; + private boolean requireNewbie; + private boolean requirePriorJams; + private boolean requireNoPriorProjects; + private boolean requirePriorProjects; + private List allowedLicenses = new ArrayList<>(); + private List allowedClassifications = new ArrayList<>(); + private List allowedGameVersions = new ArrayList<>(); + private String requiredDependencyId; + private String requiredClassUsage; + + public boolean isRequireNewProject() { return requireNewProject; } + public void setRequireNewProject(boolean requireNewProject) { this.requireNewProject = requireNewProject; } + public boolean isRequireSourceRepo() { return requireSourceRepo; } + public void setRequireSourceRepo(boolean requireSourceRepo) { this.requireSourceRepo = requireSourceRepo; } + public boolean isRequireOsiLicense() { return requireOsiLicense; } + public void setRequireOsiLicense(boolean requireOsiLicense) { this.requireOsiLicense = requireOsiLicense; } + public Integer getMinContributors() { return minContributors; } + public void setMinContributors(Integer minContributors) { this.minContributors = minContributors; } + public Integer getMaxContributors() { return maxContributors; } + public void setMaxContributors(Integer maxContributors) { this.maxContributors = maxContributors; } + public boolean isRequireUniqueSubmission() { return requireUniqueSubmission; } + public void setRequireUniqueSubmission(boolean requireUniqueSubmission) { this.requireUniqueSubmission = requireUniqueSubmission; } + public boolean isRequireNewbie() { return requireNewbie; } + public void setRequireNewbie(boolean requireNewbie) { this.requireNewbie = requireNewbie; } + public boolean isRequirePriorJams() { return requirePriorJams; } + public void setRequirePriorJams(boolean requirePriorJams) { this.requirePriorJams = requirePriorJams; } + public boolean isRequireNoPriorProjects() { return requireNoPriorProjects; } + public void setRequireNoPriorProjects(boolean requireNoPriorProjects) { this.requireNoPriorProjects = requireNoPriorProjects; } + public boolean isRequirePriorProjects() { return requirePriorProjects; } + public void setRequirePriorProjects(boolean requirePriorProjects) { this.requirePriorProjects = requirePriorProjects; } + public List getAllowedLicenses() { return allowedLicenses; } + public void setAllowedLicenses(List allowedLicenses) { this.allowedLicenses = allowedLicenses; } + public List getAllowedClassifications() { return allowedClassifications; } + public void setAllowedClassifications(List allowedClassifications) { this.allowedClassifications = allowedClassifications; } + public List getAllowedGameVersions() { return allowedGameVersions; } + public void setAllowedGameVersions(List allowedGameVersions) { this.allowedGameVersions = allowedGameVersions; } + public String getRequiredDependencyId() { return requiredDependencyId; } + public void setRequiredDependencyId(String requiredDependencyId) { this.requiredDependencyId = requiredDependencyId; } + public String getRequiredClassUsage() { return requiredClassUsage; } + public void setRequiredClassUsage(String requiredClassUsage) { this.requiredClassUsage = requiredClassUsage; } + } + + private List categories = new ArrayList<>(); + private Restrictions restrictions = new Restrictions(); + + private boolean allowPublicVoting; + private boolean allowConcurrentVoting; + private boolean showResultsBeforeVotingEnds; + private boolean oneEntryPerPerson = true; + private boolean hideSubmissions; + + private Instant createdAt = Instant.now(); + private Instant updatedAt = Instant.now(); + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getRules() { return rules; } + public void setRules(String rules) { this.rules = rules; } + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public String getBannerUrl() { return bannerUrl; } + public void setBannerUrl(String bannerUrl) { this.bannerUrl = bannerUrl; } + public String getHostId() { return hostId; } + public void setHostId(String hostId) { this.hostId = hostId; } + public String getHostName() { return hostName; } + public void setHostName(String hostName) { this.hostName = hostName; } + public Instant getStartDate() { return startDate; } + public void setStartDate(Instant startDate) { this.startDate = startDate; } + public Instant getEndDate() { return endDate; } + public void setEndDate(Instant endDate) { this.endDate = endDate; } + public Instant getVotingEndDate() { return votingEndDate; } + public void setVotingEndDate(Instant votingEndDate) { this.votingEndDate = votingEndDate; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public List getParticipantIds() { return participantIds; } + public void setParticipantIds(List participantIds) { this.participantIds = participantIds; } + public List getJudgeIds() { return judgeIds; } + public void setJudgeIds(List judgeIds) { this.judgeIds = judgeIds; } + public List getPendingJudgeInvites() { return pendingJudgeInvites; } + public void setPendingJudgeInvites(List pendingJudgeInvites) { this.pendingJudgeInvites = pendingJudgeInvites; } + public List> getJudgeProfiles() { return judgeProfiles; } + public void setJudgeProfiles(List> judgeProfiles) { this.judgeProfiles = judgeProfiles; } + public List getCategories() { return categories; } + public void setCategories(List categories) { this.categories = categories; } + public Restrictions getRestrictions() { return restrictions; } + public void setRestrictions(Restrictions restrictions) { this.restrictions = restrictions; } + public boolean isAllowPublicVoting() { return allowPublicVoting; } + public void setAllowPublicVoting(boolean allowPublicVoting) { this.allowPublicVoting = allowPublicVoting; } + public boolean isAllowConcurrentVoting() { return allowConcurrentVoting; } + public void setAllowConcurrentVoting(boolean allowConcurrentVoting) { this.allowConcurrentVoting = allowConcurrentVoting; } + public boolean isShowResultsBeforeVotingEnds() { return showResultsBeforeVotingEnds; } + public void setShowResultsBeforeVotingEnds(boolean showResultsBeforeVotingEnds) { this.showResultsBeforeVotingEnds = showResultsBeforeVotingEnds; } + public boolean isOneEntryPerPerson() { return oneEntryPerPerson; } + public void setOneEntryPerPerson(boolean oneEntryPerPerson) { this.oneEntryPerPerson = oneEntryPerPerson; } + public boolean isHideSubmissions() { return hideSubmissions; } + public void setHideSubmissions(boolean hideSubmissions) { this.hideSubmissions = hideSubmissions; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/model/jam/ModjamSubmission.java b/backend/src/main/java/net/modtale/model/jam/ModjamSubmission.java new file mode 100644 index 00000000..13e2a357 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/jam/ModjamSubmission.java @@ -0,0 +1,120 @@ +package net.modtale.model.jam; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Document(collection = "modjam_submissions") +public class ModjamSubmission { + + @Id + private String id; + private String jamId; + private String projectId; + + private String projectTitle; + private String projectImageUrl; + private String projectBannerUrl; + private String projectAuthor; + private String projectDescription; + + private String submitterId; + private List votes = new ArrayList<>(); + + private Map categoryScores; + private Double totalScore; + + private Map judgeCategoryScores; + private Double totalJudgeScore; + private Double totalPublicScore; + + private Integer rank; + + private boolean isWinner; + private String awardTitle; + + private Instant createdAt = Instant.now(); + + @Transient + private Integer votesCast; + + @Transient + private Integer commentsGiven; + + public static class Vote { + private String id; + private String voterId; + private String categoryId; + private int score; + private boolean isJudge; + + public Vote() {} + + public Vote(String id, String voterId, String categoryId, int score, boolean isJudge) { + this.id = id; + this.voterId = voterId; + this.categoryId = categoryId; + this.score = score; + this.isJudge = isJudge; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getVoterId() { return voterId; } + public void setVoterId(String voterId) { this.voterId = voterId; } + public String getCategoryId() { return categoryId; } + public void setCategoryId(String categoryId) { this.categoryId = categoryId; } + public int getScore() { return score; } + public void setScore(int score) { this.score = score; } + public boolean isJudge() { return isJudge; } + public void setJudge(boolean judge) { isJudge = judge; } + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getJamId() { return jamId; } + public void setJamId(String jamId) { this.jamId = jamId; } + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public String getProjectTitle() { return projectTitle; } + public void setProjectTitle(String projectTitle) { this.projectTitle = projectTitle; } + public String getProjectImageUrl() { return projectImageUrl; } + public void setProjectImageUrl(String projectImageUrl) { this.projectImageUrl = projectImageUrl; } + public String getProjectBannerUrl() { return projectBannerUrl; } + public void setProjectBannerUrl(String projectBannerUrl) { this.projectBannerUrl = projectBannerUrl; } + public String getProjectAuthor() { return projectAuthor; } + public void setProjectAuthor(String projectAuthor) { this.projectAuthor = projectAuthor; } + public String getProjectDescription() { return projectDescription; } + public void setProjectDescription(String projectDescription) { this.projectDescription = projectDescription; } + public String getSubmitterId() { return submitterId; } + public void setSubmitterId(String submitterId) { this.submitterId = submitterId; } + public List getVotes() { return votes; } + public void setVotes(List votes) { this.votes = votes; } + public Map getCategoryScores() { return categoryScores; } + public void setCategoryScores(Map categoryScores) { this.categoryScores = categoryScores; } + public Double getTotalScore() { return totalScore; } + public void setTotalScore(Double totalScore) { this.totalScore = totalScore; } + public Map getJudgeCategoryScores() { return judgeCategoryScores; } + public void setJudgeCategoryScores(Map judgeCategoryScores) { this.judgeCategoryScores = judgeCategoryScores; } + public Double getTotalJudgeScore() { return totalJudgeScore; } + public void setTotalJudgeScore(Double totalJudgeScore) { this.totalJudgeScore = totalJudgeScore; } + public Double getTotalPublicScore() { return totalPublicScore; } + public void setTotalPublicScore(Double totalPublicScore) { this.totalPublicScore = totalPublicScore; } + public Integer getRank() { return rank; } + public void setRank(Integer rank) { this.rank = rank; } + public boolean isWinner() { return isWinner; } + public void setWinner(boolean winner) { this.isWinner = winner; } + public String getAwardTitle() { return awardTitle; } + public void setAwardTitle(String awardTitle) { this.awardTitle = awardTitle; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Integer getVotesCast() { return votesCast; } + public void setVotesCast(Integer votesCast) { this.votesCast = votesCast; } + public Integer getCommentsGiven() { return commentsGiven; } + public void setCommentsGiven(Integer commentsGiven) { this.commentsGiven = commentsGiven; } +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/model/project/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index 6a5998db..cf83f456 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -171,6 +171,8 @@ public ProjectMember(String userId, String roleId) { private List childProjectIds; private List modIds; + private List modjamIds = new ArrayList<>(); + private boolean allowModpacks = true; private boolean allowComments = true; @@ -271,6 +273,10 @@ public Project() {} public void setChildProjectIds(List childProjectIds) { this.childProjectIds = childProjectIds; } public List getModIds() { return modIds; } public void setModIds(List modIds) { this.modIds = modIds; } + + public List getModjamIds() { return modjamIds; } + public void setModjamIds(List modjamIds) { this.modjamIds = modjamIds; } + public boolean isAllowModpacks() { return allowModpacks; } public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index a33c9eb5..15c48ff5 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -68,6 +68,8 @@ public class User implements Serializable { private List followingIds = new ArrayList<>(); private List followerIds = new ArrayList<>(); + private List joinedModjamIds = new ArrayList<>(); + private List connectedAccounts = new ArrayList<>(); private List badges = new ArrayList<>(); @@ -275,6 +277,9 @@ public ConnectedAccount(OAuthProvider provider, String providerId, String userna public List getFollowerIds() { return followerIds; } public void setFollowerIds(List followerIds) { this.followerIds = followerIds; } + public List getJoinedModjamIds() { return joinedModjamIds; } + public void setJoinedModjamIds(List joinedModjamIds) { this.joinedModjamIds = joinedModjamIds; } + public List getConnectedAccounts() { return connectedAccounts; } public void setConnectedAccounts(List connectedAccounts) { this.connectedAccounts = connectedAccounts; } diff --git a/backend/src/main/java/net/modtale/repository/jam/ModjamRepository.java b/backend/src/main/java/net/modtale/repository/jam/ModjamRepository.java new file mode 100644 index 00000000..0f074cb2 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/jam/ModjamRepository.java @@ -0,0 +1,17 @@ +package net.modtale.repository.jam; + +import net.modtale.model.jam.Modjam; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ModjamRepository extends MongoRepository { + Optional findBySlug(String slug); + List findByStatusIn(List statuses); + List findByHostId(String hostId); + List findByStatusAndUpdatedAtBefore(String status, Instant date); +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/repository/jam/ModjamSubmissionRepository.java b/backend/src/main/java/net/modtale/repository/jam/ModjamSubmissionRepository.java new file mode 100644 index 00000000..f00565d0 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/jam/ModjamSubmissionRepository.java @@ -0,0 +1,14 @@ +package net.modtale.repository.jam; + +import net.modtale.model.jam.ModjamSubmission; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ModjamSubmissionRepository extends MongoRepository { + List findByJamId(String jamId); + List findByJamIdAndSubmitterId(String jamId, String submitterId); + List findByProjectId(String projectId); +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/service/ModjamService.java b/backend/src/main/java/net/modtale/service/ModjamService.java new file mode 100644 index 00000000..056ff17e --- /dev/null +++ b/backend/src/main/java/net/modtale/service/ModjamService.java @@ -0,0 +1,974 @@ +package net.modtale.service; + +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.storage.StorageService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; +import java.util.zip.ZipFile; + +@Service +public class ModjamService { + + @Autowired private ModjamRepository modjamRepository; + @Autowired private ModjamSubmissionRepository submissionRepository; + @Autowired private UserRepository userRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private StorageService storageService; + @Autowired private MongoTemplate mongoTemplate; + @Autowired private AccountService accountService; + + @Value("${app.r2.public-domain:#{null}}") + private String publicDomain; + + private static final RuntimeException FOUND_USAGE = new RuntimeException("Found usage of class or package", null, false, false) {}; + + public static final class CheckJarUseClass { + public static boolean checkUseClass(final File file, final String classOrPackage) { + final String searchPrefix = classOrPackage.replace('.', '/').replace("*", ""); + try (final ZipFile zipFile = new ZipFile(file)) { + zipFile.stream().filter(entry -> entry.getName().endsWith(".class")) + .forEach(zipEntry -> { + try { + new ClassReader(zipFile.getInputStream(zipEntry)) + .accept(new PrefixUsageSearcher(searchPrefix), + ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (Exception e) { + if (e == FOUND_USAGE) { + return true; + } + } + return false; + } + + private static class PrefixUsageSearcher extends ClassVisitor { + private final String prefix; + + public PrefixUsageSearcher(String prefix) { + super(Opcodes.ASM9); + this.prefix = prefix; + } + + private void check(String internalName) { + if (internalName != null && internalName.startsWith(prefix)) { + throw FOUND_USAGE; + } + } + + private void checkDescriptor(String descriptor) { + if (descriptor != null && descriptor.contains(prefix)) { + throw FOUND_USAGE; + } + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + check(superName); + if (interfaces != null) { + for (String i : interfaces) check(i); + } + super.visit(version, access, name, signature, superName, interfaces); + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + checkDescriptor(descriptor); + return super.visitField(access, name, descriptor, signature, value); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + checkDescriptor(descriptor); + return new MethodVisitor(Opcodes.ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) { + @Override + public void visitMethodInsn(int opcode, String owner, String mName, String mDescriptor, boolean isInterface) { + check(owner); + super.visitMethodInsn(opcode, owner, mName, mDescriptor, isInterface); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String fName, String fDescriptor) { + check(owner); + super.visitFieldInsn(opcode, owner, fName, fDescriptor); + } + + @Override + public void visitTypeInsn(int opcode, String type) { + check(type); + super.visitTypeInsn(opcode, type); + } + }; + } + } + } + + private Modjam enrichAndReturn(Modjam jam) { + if (jam != null && jam.getJudgeIds() != null && !jam.getJudgeIds().isEmpty()) { + List> profiles = new ArrayList<>(); + for (String jId : jam.getJudgeIds()) { + userRepository.findById(jId).ifPresent(u -> { + Map p = new HashMap<>(); + p.put("id", u.getId()); + p.put("username", u.getUsername()); + p.put("avatarUrl", u.getAvatarUrl()); + profiles.add(p); + }); + } + jam.setJudgeProfiles(profiles); + } + return jam; + } + + private void enrichSubmissions(String jamId, List subs, Map projectMap) { + if (subs == null || subs.isEmpty()) return; + + Map userVoteCount = new HashMap<>(); + Set visibleProjectIds = new HashSet<>(); + + for (ModjamSubmission s : subs) { + visibleProjectIds.add(s.getProjectId()); + if (s.getVotes() != null) { + for (ModjamSubmission.Vote v : s.getVotes()) { + userVoteCount.put(v.getVoterId(), userVoteCount.getOrDefault(v.getVoterId(), 0) + 1); + } + } + } + + Map userCommentCount = new HashMap<>(); + for (Project p : projectMap.values()) { + if (visibleProjectIds.contains(p.getId()) && p.getComments() != null) { + for (net.modtale.model.project.Comment c : p.getComments()) { + if (!c.getUserId().equals(p.getAuthorId())) { + userCommentCount.put(c.getUserId(), userCommentCount.getOrDefault(c.getUserId(), 0) + 1); + } + } + } + } + + for (ModjamSubmission sub : subs) { + Project project = projectMap.get(sub.getProjectId()); + if (project != null) { + sub.setProjectTitle(project.getTitle()); + sub.setProjectImageUrl(project.getImageUrl()); + sub.setProjectBannerUrl(project.getBannerUrl()); + sub.setProjectAuthor(project.getAuthor()); + sub.setProjectDescription(project.getDescription()); + } + sub.setVotesCast(userVoteCount.getOrDefault(sub.getSubmitterId(), 0)); + sub.setCommentsGiven(userCommentCount.getOrDefault(sub.getProjectAuthor(), 0)); + } + } + + private void enrichSubmissions(String jamId, List subs) { + if (subs == null || subs.isEmpty()) return; + List projectIds = subs.stream().map(ModjamSubmission::getProjectId).toList(); + Iterable projectsIterable = projectRepository.findAllById(projectIds); + Map projectMap = new HashMap<>(); + projectsIterable.forEach(p -> projectMap.put(p.getId(), p)); + enrichSubmissions(jamId, subs, projectMap); + } + + private String extractStorageKey(String fileUrl) { + if (fileUrl == null) return null; + if (fileUrl.startsWith("/api/files/proxy/")) { + return fileUrl.replace("/api/files/proxy/", ""); + } else if (publicDomain != null && fileUrl.startsWith(publicDomain + "/")) { + return fileUrl.replace(publicDomain + "/", ""); + } else if (publicDomain != null && fileUrl.startsWith(publicDomain)) { + return fileUrl.replace(publicDomain, ""); + } + return fileUrl; + } + + public List getAllJams() { + return modjamRepository.findAll().stream().map(this::enrichAndReturn).collect(Collectors.toList()); + } + + public List getUserHostedJams(String hostId) { + return modjamRepository.findByHostId(hostId).stream().map(this::enrichAndReturn).collect(Collectors.toList()); + } + + public Modjam getJamBySlug(String slug) { + return enrichAndReturn(modjamRepository.findBySlug(slug) + .orElseThrow(() -> new IllegalArgumentException("Jam not found"))); + } + + public Modjam createJam(Modjam jam, String hostId, String hostName) { + jam.setId(null); + jam.setHostId(hostId); + jam.setHostName(hostName); + + if (jam.getSlug() == null || jam.getSlug().trim().isEmpty()) { + throw new IllegalArgumentException("A custom URL slug is required."); + } + String newSlug = jam.getSlug().toLowerCase(); + if (!newSlug.matches("^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])?$")) { + throw new IllegalArgumentException("Invalid URL Slug. Must be 3-50 characters, lowercase alphanumeric with dashes, and cannot start or end with a dash."); + } + if (modjamRepository.findBySlug(newSlug).isPresent()) { + throw new IllegalArgumentException("Jam URL '" + newSlug + "' is already taken."); + } + jam.setSlug(newSlug); + + if (jam.getStartDate() != null && jam.getStartDate().isAfter(Instant.now())) { + jam.setStatus("UPCOMING"); + } else if (!"DRAFT".equals(jam.getStatus())) { + jam.setStatus("ACTIVE"); + } + + jam.setCreatedAt(Instant.now()); + jam.setUpdatedAt(Instant.now()); + + if (jam.getCategories() != null) { + for (Modjam.Category cat : jam.getCategories()) { + if (cat.getId() == null || cat.getId().trim().isEmpty()) { + cat.setId(UUID.randomUUID().toString()); + } + } + } else { + jam.setCategories(new ArrayList<>()); + } + + if (jam.getJudgeIds() == null) jam.setJudgeIds(new ArrayList<>()); + if (jam.getPendingJudgeInvites() == null) jam.setPendingJudgeInvites(new ArrayList<>()); + + return enrichAndReturn(modjamRepository.save(jam)); + } + + public Modjam updateJam(String id, Modjam updatedJam) { + Modjam jam = modjamRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (updatedJam.getSlug() == null || updatedJam.getSlug().trim().isEmpty()) { + throw new IllegalArgumentException("A custom URL slug is required."); + } + String newSlug = updatedJam.getSlug().toLowerCase(); + if (!newSlug.equals(jam.getSlug())) { + if (!newSlug.matches("^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])?$")) { + throw new IllegalArgumentException("Invalid URL Slug. Must be 3-50 characters, lowercase alphanumeric with dashes, and cannot start or end with a dash."); + } + if (modjamRepository.findBySlug(newSlug).isPresent()) { + throw new IllegalArgumentException("Jam URL '" + newSlug + "' is already taken."); + } + jam.setSlug(newSlug); + } + + String oldStatus = jam.getStatus(); + + jam.setTitle(updatedJam.getTitle()); + jam.setDescription(updatedJam.getDescription()); + jam.setRules(updatedJam.getRules()); + jam.setStartDate(updatedJam.getStartDate()); + jam.setEndDate(updatedJam.getEndDate()); + jam.setVotingEndDate(updatedJam.getVotingEndDate()); + jam.setAllowPublicVoting(updatedJam.isAllowPublicVoting()); + jam.setAllowConcurrentVoting(updatedJam.isAllowConcurrentVoting()); + jam.setShowResultsBeforeVotingEnds(updatedJam.isShowResultsBeforeVotingEnds()); + jam.setOneEntryPerPerson(updatedJam.isOneEntryPerPerson()); + jam.setHideSubmissions(updatedJam.isHideSubmissions()); + + if (updatedJam.getRestrictions() != null) { + jam.setRestrictions(updatedJam.getRestrictions()); + } + + if (updatedJam.getCategories() != null) { + for (Modjam.Category cat : updatedJam.getCategories()) { + if (cat.getId() == null || cat.getId().trim().isEmpty()) { + cat.setId(UUID.randomUUID().toString()); + } + } + jam.setCategories(updatedJam.getCategories()); + } else { + jam.setCategories(new ArrayList<>()); + } + + if (!"COMPLETED".equals(jam.getStatus()) && "COMPLETED".equals(updatedJam.getStatus())) { + calculateScores(jam.getId()); + } + + String targetStatus = updatedJam.getStatus(); + if (!"COMPLETED".equals(targetStatus) && !"DRAFT".equals(targetStatus)) { + Instant now = Instant.now(); + if (jam.getStartDate() != null && now.isBefore(jam.getStartDate())) { + targetStatus = "UPCOMING"; + } else if (jam.getEndDate() != null && now.isBefore(jam.getEndDate())) { + targetStatus = "ACTIVE"; + } else if (jam.getVotingEndDate() != null && now.isBefore(jam.getVotingEndDate())) { + targetStatus = "VOTING"; + } else { + targetStatus = "AWAITING_WINNERS"; + } + } + + jam.setStatus(targetStatus); + jam.setUpdatedAt(Instant.now()); + + if (jam.isHideSubmissions() && !List.of("VOTING", "COMPLETED", "AWAITING_WINNERS").contains(oldStatus) + && List.of("VOTING", "COMPLETED", "AWAITING_WINNERS").contains(targetStatus)) { + revealHiddenJamProjects(jam.getId()); + } + + return enrichAndReturn(modjamRepository.save(jam)); + } + + public Modjam inviteJudge(String jamId, String username, String hostId) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + if (!jam.getHostId().equals(hostId)) throw new SecurityException("Only the host can invite judges."); + + Query query = new Query(Criteria.where("username").regex("^" + username + "$", "i")); + User targetUser = mongoTemplate.findOne(query, User.class); + + if (targetUser == null) { + throw new IllegalArgumentException("User '" + username + "' not found."); + } + + if (targetUser.getId().equals(hostId)) { + throw new IllegalArgumentException("You cannot invite yourself."); + } + + if (jam.getPendingJudgeInvites() == null) jam.setPendingJudgeInvites(new ArrayList<>()); + if (jam.getJudgeIds() == null) jam.setJudgeIds(new ArrayList<>()); + + if (jam.getJudgeIds().contains(targetUser.getId())) { + throw new IllegalArgumentException("User is already a judge."); + } + if (jam.getPendingJudgeInvites().contains(targetUser.getUsername())) { + throw new IllegalArgumentException("User is already invited."); + } + + jam.getPendingJudgeInvites().add(targetUser.getUsername()); + + org.bson.Document notif = new org.bson.Document(); + notif.put("userId", targetUser.getId()); + notif.put("title", "Jam Judge Invitation"); + notif.put("message", "You have been invited to be a judge for " + jam.getTitle()); + notif.put("link", "/jam/" + jam.getSlug() + "/overview"); + notif.put("read", false); + notif.put("createdAt", Instant.now()); + mongoTemplate.save(notif, "notifications"); + + return enrichAndReturn(modjamRepository.save(jam)); + } + + public Modjam removeJudge(String jamId, String username, String hostId) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + if (!jam.getHostId().equals(hostId)) throw new SecurityException("Only the host can remove judges."); + + if (jam.getPendingJudgeInvites() != null) { + jam.getPendingJudgeInvites().removeIf(u -> u.equalsIgnoreCase(username)); + } + + if (jam.getJudgeIds() != null) { + Query query = new Query(Criteria.where("username").regex("^" + username + "$", "i")); + User targetUser = mongoTemplate.findOne(query, User.class); + if (targetUser != null) { + jam.getJudgeIds().remove(targetUser.getId()); + } + } + + return enrichAndReturn(modjamRepository.save(jam)); + } + + public Modjam acceptJudgeInvite(String jamId, String userId, String username) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (jam.getPendingJudgeInvites() == null || jam.getPendingJudgeInvites().stream().noneMatch(u -> u.equalsIgnoreCase(username))) { + throw new IllegalArgumentException("You don't have a pending invite for this jam."); + } + + jam.getPendingJudgeInvites().removeIf(u -> u.equalsIgnoreCase(username)); + + if (jam.getJudgeIds() == null) jam.setJudgeIds(new ArrayList<>()); + if (!jam.getJudgeIds().contains(userId)) { + jam.getJudgeIds().add(userId); + } + + return enrichAndReturn(modjamRepository.save(jam)); + } + + public Modjam declineJudgeInvite(String jamId, String username) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (jam.getPendingJudgeInvites() != null) { + jam.getPendingJudgeInvites().removeIf(u -> u.equalsIgnoreCase(username)); + } + + return enrichAndReturn(modjamRepository.save(jam)); + } + + public void updateIcon(String jamId, MultipartFile file) { + try { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + String pathPrefix = "modjams/" + jamId + "/icon"; + String storageKey = storageService.upload(file, pathPrefix); + String publicUrl = storageService.getPublicUrl(storageKey); + jam.setImageUrl(publicUrl); + jam.setUpdatedAt(Instant.now()); + modjamRepository.save(jam); + } catch (Exception e) { + throw new RuntimeException("Failed to upload icon", e); + } + } + + public void updateBanner(String jamId, MultipartFile file) { + try { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + String pathPrefix = "modjams/" + jamId + "/banner"; + String storageKey = storageService.upload(file, pathPrefix); + String publicUrl = storageService.getPublicUrl(storageKey); + jam.setBannerUrl(publicUrl); + jam.setUpdatedAt(Instant.now()); + modjamRepository.save(jam); + } catch (Exception e) { + throw new RuntimeException("Failed to upload banner", e); + } + } + + public void deleteJam(String jamId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (!jam.getHostId().equals(userId)) { + throw new SecurityException("Only the host can delete this jam"); + } + + List submissions = submissionRepository.findByJamId(jamId); + if (submissions != null && !submissions.isEmpty()) { + submissionRepository.deleteAll(submissions); + } + + modjamRepository.delete(jam); + } + + public List getSubmissions(String jamId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + List allSubs = submissionRepository.findByJamId(jamId); + + if (allSubs == null || allSubs.isEmpty()) return new ArrayList<>(); + + User currentUser = accountService.getCurrentUser(); + boolean isAdmin = currentUser != null && currentUser.getRoles() != null && currentUser.getRoles().contains("ADMIN"); + boolean isHost = currentUser != null && currentUser.getId().equals(jam.getHostId()); + + boolean isJamHiding = jam.isHideSubmissions() && List.of("DRAFT", "UPCOMING", "ACTIVE").contains(jam.getStatus()); + + List projectIds = allSubs.stream().map(ModjamSubmission::getProjectId).toList(); + Iterable projectsIterable = projectRepository.findAllById(projectIds); + Map projectMap = new HashMap<>(); + projectsIterable.forEach(p -> projectMap.put(p.getId(), p)); + + List visibleSubs = new ArrayList<>(); + + for (ModjamSubmission sub : allSubs) { + Project project = projectMap.get(sub.getProjectId()); + if (project == null) continue; + + boolean isSubmitter = currentUser != null && currentUser.getId().equals(sub.getSubmitterId()); + boolean canSeeHidden = isAdmin || isHost || isSubmitter; + boolean isPublicProject = project.getStatus() == ProjectStatus.PUBLISHED || project.getStatus() == ProjectStatus.ARCHIVED; + + if (!canSeeHidden) { + if (!isPublicProject || isJamHiding) { + continue; // Backend enforces filtering out unreleased or jam-hidden projects + } + } + visibleSubs.add(sub); + } + + enrichSubmissions(jamId, visibleSubs, projectMap); + return visibleSubs; + } + + public Modjam participate(String jamId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (user.getJoinedModjamIds() != null) { + for (String joinedId : user.getJoinedModjamIds()) { + if (joinedId.equals(jamId)) continue; + + Optional optOtherJam = modjamRepository.findById(joinedId); + if (optOtherJam.isPresent()) { + Modjam otherJam = optOtherJam.get(); + boolean otherIsActive = "ACTIVE".equals(otherJam.getStatus()) || "UPCOMING".equals(otherJam.getStatus()); + boolean thisIsActive = "ACTIVE".equals(jam.getStatus()) || "UPCOMING".equals(jam.getStatus()); + + if (otherIsActive && thisIsActive) { + boolean otherRequiresUnique = otherJam.getRestrictions() != null && otherJam.getRestrictions().isRequireUniqueSubmission(); + boolean thisRequiresUnique = jam.getRestrictions() != null && jam.getRestrictions().isRequireUniqueSubmission(); + + if (otherRequiresUnique) { + throw new IllegalStateException("You are currently participating in '" + otherJam.getTitle() + "' which requires unique participation. You must leave it to join this jam."); + } + if (thisRequiresUnique) { + throw new IllegalStateException("This jam requires unique participation. You are currently in '" + otherJam.getTitle() + "'. You must leave it to join this jam."); + } + } + } + } + } + + if (jam.getParticipantIds() == null) { + jam.setParticipantIds(new ArrayList<>()); + } + + if (!jam.getParticipantIds().contains(userId)) { + jam.getParticipantIds().add(userId); + modjamRepository.save(jam); + } + + if (user.getJoinedModjamIds() == null) { + user.setJoinedModjamIds(new ArrayList<>()); + } + + if (!user.getJoinedModjamIds().contains(jamId)) { + user.getJoinedModjamIds().add(jamId); + userRepository.save(user); + } + + return enrichAndReturn(jam); + } + + public Modjam leaveJam(String jamId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + List existingSubs = submissionRepository.findByJamIdAndSubmitterId(jamId, userId); + if (!existingSubs.isEmpty()) { + throw new IllegalArgumentException("Cannot leave a jam after submitting a project."); + } + + if (jam.getParticipantIds() != null) { + jam.getParticipantIds().remove(userId); + modjamRepository.save(jam); + } + + if (user.getJoinedModjamIds() != null) { + user.getJoinedModjamIds().remove(jamId); + userRepository.save(user); + } + + return enrichAndReturn(jam); + } + + public ModjamSubmission submitProject(String jamId, String projectId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (!"ACTIVE".equals(jam.getStatus())) { + throw new IllegalArgumentException("Submissions are closed."); + } + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found")); + + if (!project.getAuthorId().equals(userId)) { + throw new SecurityException("Not your project"); + } + + if (!List.of(ProjectStatus.PUBLISHED, ProjectStatus.PENDING, ProjectStatus.UNLISTED, ProjectStatus.DRAFT).contains(project.getStatus())) { + throw new IllegalArgumentException("Project cannot be submitted in its current state."); + } + + if (jam.isHideSubmissions() && project.getStatus() == ProjectStatus.PUBLISHED) { + throw new IllegalArgumentException("This jam hides submissions until voting opens. You cannot submit an already-public project."); + } + + List existing = submissionRepository.findByJamIdAndSubmitterId(jamId, userId); + + if (jam.isOneEntryPerPerson() && !existing.isEmpty()) { + throw new IllegalArgumentException("This jam is restricted to one entry per person."); + } + + if (existing.stream().anyMatch(s -> s.getProjectId().equals(projectId))) { + throw new IllegalArgumentException("Already submitted."); + } + + if (project.getStatus() == ProjectStatus.DRAFT) { + try { + project.setStatus(ProjectStatus.PENDING); + projectRepository.save(project); + project = projectRepository.findById(projectId).orElseThrow(() -> new IllegalArgumentException("Project not found")); + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + Modjam.Restrictions res = jam.getRestrictions(); + if (res != null) { + if (res.isRequireNoPriorProjects() || res.isRequirePriorProjects()) { + long priorPublishedProjects = projectRepository.findByAuthorIdList(userId).stream() + .filter(p -> !p.getId().equals(projectId) && p.getStatus() == ProjectStatus.PUBLISHED) + .count(); + + if (res.isRequireNoPriorProjects() && priorPublishedProjects > 0) { + throw new IllegalArgumentException("This jam is restricted to users who have never published a project before."); + } + if (res.isRequirePriorProjects() && priorPublishedProjects == 0) { + throw new IllegalArgumentException("This jam is restricted to users who have previously published at least one project."); + } + } + + if (res.isRequireNewProject() && jam.getStartDate() != null && project.getCreatedAt() != null) { + try { + Instant projCreated; + try { + projCreated = Instant.parse(project.getCreatedAt()); + } catch (Exception e) { + String cleanDate = project.getCreatedAt().replace("Z", ""); + projCreated = LocalDateTime.parse(cleanDate).toInstant(ZoneOffset.UTC); + } + if (projCreated.isBefore(jam.getStartDate())) { + throw new IllegalArgumentException("Project must be created after the jam start date."); + } + } catch (IllegalArgumentException rse) { + throw rse; + } catch (Exception ignored) {} + } + + if (res.isRequireSourceRepo() && (project.getRepositoryUrl() == null || project.getRepositoryUrl().trim().isEmpty())) { + throw new IllegalArgumentException("Project must have a linked public source repository."); + } + + if (res.isRequireOsiLicense()) { + String l = project.getLicense() != null ? project.getLicense().toUpperCase().replaceAll("[^A-Z0-9]", "") : ""; + boolean isOsi = l.contains("MIT") || l.contains("APACHE") || l.contains("LGPL") || l.contains("AGPL") || l.contains("GPL") || l.contains("MPL") || l.contains("BSD") || l.contains("UNLICENSE") || l.contains("CC0"); + if (!isOsi) { + throw new IllegalArgumentException("Project must use an OSI-approved open source license."); + } + } + + if (res.getAllowedClassifications() != null && !res.getAllowedClassifications().isEmpty()) { + if (!res.getAllowedClassifications().contains(project.getClassification().name())) { + throw new IllegalArgumentException("Project classification is not allowed for this jam."); + } + } + + if (res.getAllowedLicenses() != null && !res.getAllowedLicenses().isEmpty()) { + if (!res.getAllowedLicenses().contains(project.getLicense())) { + throw new IllegalArgumentException("Project license is not allowed for this jam."); + } + } + + if (res.getAllowedGameVersions() != null && !res.getAllowedGameVersions().isEmpty()) { + boolean hasValidVersion = false; + if (project.getVersions() != null) { + for (ProjectVersion pv : project.getVersions()) { + if (pv.getGameVersions() != null) { + for (String gv : pv.getGameVersions()) { + if (res.getAllowedGameVersions().contains(gv)) { + hasValidVersion = true; + break; + } + } + } + if (hasValidVersion) break; + } + } + if (!hasValidVersion) { + throw new IllegalArgumentException("Project does not support any of the required game versions."); + } + } + + if (res.getRequiredDependencyId() != null && !res.getRequiredDependencyId().trim().isEmpty()) { + if (project.getModIds() == null || !project.getModIds().contains(res.getRequiredDependencyId().trim())) { + throw new IllegalArgumentException("Project is missing the required dependency."); + } + } + + int contributorCount = (project.getTeamMembers() != null ? project.getTeamMembers().size() : 0) + 1; + if (res.getMinContributors() != null && contributorCount < res.getMinContributors()) { + throw new IllegalArgumentException("Project does not meet the minimum contributor requirement."); + } + + if (res.getMaxContributors() != null && contributorCount > res.getMaxContributors()) { + throw new IllegalArgumentException("Project exceeds the maximum contributor limit."); + } + + if (res.isRequireUniqueSubmission() && project.getModjamIds() != null) { + for (String otherJamId : project.getModjamIds()) { + if (otherJamId.equals(jamId)) continue; + modjamRepository.findById(otherJamId).ifPresent(otherJam -> { + if ("ACTIVE".equals(otherJam.getStatus()) || "VOTING".equals(otherJam.getStatus())) { + throw new IllegalArgumentException("Project is currently entered in another active jam."); + } + }); + } + } + + if (res.isRequireNewbie()) { + User u = userRepository.findById(userId).orElse(null); + if (u != null && u.getJoinedModjamIds() != null) { + long activeJams = u.getJoinedModjamIds().stream().filter(id -> !id.equals(jamId)).count(); + if (activeJams > 0) { + throw new IllegalArgumentException("This jam is restricted to first-time participants."); + } + } + } + + if (res.isRequirePriorJams()) { + User u = userRepository.findById(userId).orElse(null); + if (u == null || u.getJoinedModjamIds() == null || u.getJoinedModjamIds().stream().filter(id -> !id.equals(jamId)).count() == 0) { + throw new IllegalArgumentException("This jam is restricted to experienced participants who have joined a jam before."); + } + } + + if (res.getRequiredClassUsage() != null && !res.getRequiredClassUsage().trim().isEmpty()) { + if (project.getVersions() == null || project.getVersions().isEmpty()) { + throw new IllegalArgumentException("Project has no uploaded files to check."); + } + + ProjectVersion latestVersion = project.getVersions().get(project.getVersions().size() - 1); + String fileUrl = latestVersion.getFileUrl(); + if (fileUrl == null || fileUrl.isEmpty()) { + throw new IllegalArgumentException("Project version has no file associated."); + } + + String storageKey = extractStorageKey(fileUrl); + File tempFile = null; + try { + InputStream is = storageService.getStream(storageKey); + tempFile = Files.createTempFile("jam_check_", ".jar").toFile(); + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + is.transferTo(fos); + } + + boolean usesClass = CheckJarUseClass.checkUseClass(tempFile, res.getRequiredClassUsage().trim()); + if (!usesClass) { + throw new IllegalArgumentException("Project does not use the required class/package: " + res.getRequiredClassUsage().trim()); + } + } catch (IllegalArgumentException rse) { + throw rse; + } catch (Exception e) { + throw new IllegalStateException("Failed to analyze project file for required class usage."); + } finally { + if (tempFile != null && tempFile.exists()) { + tempFile.delete(); + } + } + } + } + + ModjamSubmission sub = new ModjamSubmission(); + sub.setJamId(jamId); + sub.setProjectId(projectId); + sub.setSubmitterId(userId); + + submissionRepository.save(sub); + + if (project.getModjamIds() == null) project.setModjamIds(new ArrayList<>()); + if (!project.getModjamIds().contains(jamId)) { + project.getModjamIds().add(jamId); + projectRepository.save(project); + } + + enrichSubmissions(jamId, Collections.singletonList(sub)); + return sub; + } + + public ModjamSubmission vote(String jamId, String submissionId, String categoryId, int score, String userId) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (jam.getVotingEndDate() != null && Instant.now().isAfter(jam.getVotingEndDate())) { + throw new IllegalArgumentException("Voting has closed for this jam."); + } + + ModjamSubmission sub = submissionRepository.findById(submissionId).orElseThrow(() -> new IllegalArgumentException("Submission not found")); + + if (sub.getSubmitterId().equals(userId)) throw new SecurityException("Cannot vote on self"); + + boolean isJudge = jam.getJudgeIds() != null && jam.getJudgeIds().contains(userId); + + sub.getVotes().removeIf(v -> v.getVoterId().equals(userId) && v.getCategoryId().equals(categoryId)); + + ModjamSubmission.Vote vote = new ModjamSubmission.Vote(UUID.randomUUID().toString(), userId, categoryId, score, isJudge); + sub.getVotes().add(vote); + + submissionRepository.save(sub); + calculateScores(jamId); + + ModjamSubmission updated = submissionRepository.findById(submissionId).orElse(sub); + enrichSubmissions(jamId, Collections.singletonList(updated)); + return updated; + } + + private void calculateScores(String jamId) { + List submissions = submissionRepository.findByJamId(jamId); + if (submissions == null || submissions.isEmpty()) return; + + for (ModjamSubmission sub : submissions) { + Map> allScoresMap = new HashMap<>(); + Map> publicScoresMap = new HashMap<>(); + Map> judgeScoresMap = new HashMap<>(); + + if (sub.getVotes() != null) { + for (ModjamSubmission.Vote vote : sub.getVotes()) { + allScoresMap.computeIfAbsent(vote.getCategoryId(), k -> new ArrayList<>()).add(vote.getScore()); + if (vote.isJudge()) { + judgeScoresMap.computeIfAbsent(vote.getCategoryId(), k -> new ArrayList<>()).add(vote.getScore()); + } else { + publicScoresMap.computeIfAbsent(vote.getCategoryId(), k -> new ArrayList<>()).add(vote.getScore()); + } + } + } + + sub.setCategoryScores(calculateAveragesMap(allScoresMap)); + sub.setTotalScore(calculateOverallAverage(allScoresMap)); + + sub.setJudgeCategoryScores(calculateAveragesMap(judgeScoresMap)); + sub.setTotalJudgeScore(calculateOverallAverage(judgeScoresMap)); + + sub.setTotalPublicScore(calculateOverallAverage(publicScoresMap)); + } + + submissions.sort((s1, s2) -> Double.compare(s2.getTotalScore() != null ? s2.getTotalScore() : 0.0, s1.getTotalScore() != null ? s1.getTotalScore() : 0.0)); + + int rank = 1; + for (ModjamSubmission sub : submissions) { + sub.setRank(rank++); + submissionRepository.save(sub); + } + } + + private Map calculateAveragesMap(Map> scoresMap) { + Map averaged = new HashMap<>(); + for (Map.Entry> entry : scoresMap.entrySet()) { + double avg = entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0); + averaged.put(entry.getKey(), avg); + } + return averaged; + } + + private Double calculateOverallAverage(Map> scoresMap) { + double totalSum = 0; + int count = 0; + for (Map.Entry> entry : scoresMap.entrySet()) { + totalSum += entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0); + count++; + } + return count > 0 ? totalSum / count : 0.0; + } + + public Modjam finalizeJam(String jamId, String userId, List> winnersData) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + if (!jam.getHostId().equals(userId)) throw new SecurityException("Only the host can finalize the jam"); + + calculateScores(jamId); + + List allSubs = submissionRepository.findByJamId(jamId); + for (ModjamSubmission sub : allSubs) { + Optional> matchingWinner = winnersData.stream() + .filter(w -> w.get("submissionId").equals(sub.getId())) + .findFirst(); + + if (matchingWinner.isPresent()) { + sub.setWinner(true); + sub.setAwardTitle(matchingWinner.get().get("awardTitle")); + } else { + sub.setWinner(false); + sub.setAwardTitle(null); + } + submissionRepository.save(sub); + } + + jam.setStatus("COMPLETED"); + jam.setUpdatedAt(Instant.now()); + return enrichAndReturn(modjamRepository.save(jam)); + } + + @Scheduled(fixedDelay = 60000) + public void updateJamStates() { + List jams = modjamRepository.findAll(); + Instant now = Instant.now(); + for (Modjam jam : jams) { + if ("DRAFT".equals(jam.getStatus()) || "COMPLETED".equals(jam.getStatus())) continue; + + String newStatus = jam.getStatus(); + if (jam.getStartDate() != null && now.isBefore(jam.getStartDate())) { + newStatus = "UPCOMING"; + } else if (jam.getEndDate() != null && now.isBefore(jam.getEndDate())) { + newStatus = "ACTIVE"; + } else if (jam.getVotingEndDate() != null && now.isBefore(jam.getVotingEndDate())) { + newStatus = "VOTING"; + } else { + newStatus = "AWAITING_WINNERS"; + } + + if (!newStatus.equals(jam.getStatus())) { + String oldStatus = jam.getStatus(); + jam.setStatus(newStatus); + modjamRepository.save(jam); + + if (jam.isHideSubmissions() && !List.of("VOTING", "COMPLETED", "AWAITING_WINNERS").contains(oldStatus) + && List.of("VOTING", "COMPLETED", "AWAITING_WINNERS").contains(newStatus)) { + revealHiddenJamProjects(jam.getId()); + } + } + } + } + + @Scheduled(cron = "0 0 0 * * *") + public void cleanupStaleDrafts() { + Instant thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS); + List staleDrafts = modjamRepository.findByStatusAndUpdatedAtBefore("DRAFT", thirtyDaysAgo); + for (Modjam jam : staleDrafts) { + submissionRepository.deleteAll(submissionRepository.findByJamId(jam.getId())); + modjamRepository.delete(jam); + } + } + + private void revealHiddenJamProjects(String jamId) { + Query query = new Query( + Criteria.where("modjamIds").is(jamId) + .and("status").is(ProjectStatus.UNLISTED) + .and("deletedAt").is(null) + ); + List hidden = mongoTemplate.find(query, Project.class); + for (Project project : hidden) { + project.setStatus(ProjectStatus.PUBLISHED); + projectRepository.save(project); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/system/SitemapService.java b/backend/src/main/java/net/modtale/service/system/SitemapService.java index 27ae75fd..cde47511 100644 --- a/backend/src/main/java/net/modtale/service/system/SitemapService.java +++ b/backend/src/main/java/net/modtale/service/system/SitemapService.java @@ -1,15 +1,18 @@ package net.modtale.service.system; import java.time.LocalDate; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.HashSet; import java.util.List; import java.util.Set; import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.jam.Modjam; import net.modtale.model.project.Project; import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; +import net.modtale.service.ModjamService; import net.modtale.service.project.query.ProjectService; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -20,17 +23,20 @@ public class SitemapService { private final ProjectRepository projectRepository; private final UserRepository userRepository; private final ProjectService projectService; + private final ModjamService modjamService; private final String baseUrl; public SitemapService( ProjectRepository projectRepository, UserRepository userRepository, ProjectService projectService, + ModjamService modjamService, AppFrontendProperties frontendProperties ) { this.projectRepository = projectRepository; this.userRepository = userRepository; this.projectService = projectService; + this.modjamService = modjamService; this.baseUrl = frontendProperties.url(); } @@ -47,9 +53,29 @@ public String generateSitemap() { addUrl(xml, baseUrl + "/worlds", "0.9", today); addUrl(xml, baseUrl + "/data", "0.9", today); addUrl(xml, baseUrl + "/art", "0.9", today); + addUrl(xml, baseUrl + "/jams", "0.9", today); addUrl(xml, baseUrl + "/api-docs", "0.8", today); Set activeAuthors = new HashSet<>(); + List jams = modjamService.getAllJams(); + if (jams != null) { + for (Modjam jam : jams) { + if (jam == null || "DRAFT".equals(jam.getStatus())) continue; + + if (jam.getSlug() != null && !jam.getSlug().isBlank()) { + String priority = "COMPLETED".equals(jam.getStatus()) ? "0.6" : "0.8"; + LocalDate lastMod = jam.getUpdatedAt() != null + ? jam.getUpdatedAt().atZone(ZoneOffset.UTC).toLocalDate() + : today; + addUrl(xml, baseUrl + "/jam/" + jam.getSlug(), priority, lastMod); + } + + if (jam.getHostName() != null && !jam.getHostName().isBlank()) { + activeAuthors.add(jam.getHostName().trim()); + } + } + } + List projects = projectRepository.findAllForSitemap(); for (Project project : projects) { diff --git a/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java b/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java index a93bf274..af8704c4 100644 --- a/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java @@ -8,6 +8,7 @@ import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; +import net.modtale.service.ModjamService; import net.modtale.service.project.query.ProjectCacheService; import net.modtale.service.project.query.ProjectRouteService; import net.modtale.service.project.query.ProjectService; @@ -24,6 +25,7 @@ class SitemapControllerTest { private ProjectRepository projectRepository; private UserRepository userRepository; + private ModjamService modjamService; private ProjectService projectService; private SitemapController controller; @@ -31,6 +33,7 @@ class SitemapControllerTest { void setUp() { projectRepository = mock(ProjectRepository.class); userRepository = mock(UserRepository.class); + modjamService = mock(ModjamService.class); projectService = new ProjectService( mock(ProjectViewService.class), mock(ProjectCacheService.class), @@ -40,9 +43,11 @@ void setUp() { projectRepository, userRepository, projectService, + modjamService, new AppFrontendProperties("https://modtale.test") ); controller = new SitemapController(sitemapService); + when(modjamService.getAllJams()).thenReturn(List.of()); } @Test diff --git a/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java b/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java index deec11dd..a0bbcf1d 100644 --- a/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java +++ b/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java @@ -1,17 +1,21 @@ package net.modtale.service.system; +import java.time.Instant; import java.util.List; import java.util.Optional; import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.jam.Modjam; import net.modtale.model.project.Project; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; +import net.modtale.service.ModjamService; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -21,6 +25,7 @@ class SitemapServiceTest { private ProjectRepository projectRepository; private UserRepository userRepository; private ProjectService projectService; + private ModjamService modjamService; private SitemapService service; @BeforeEach @@ -28,12 +33,15 @@ void setUp() { projectRepository = mock(ProjectRepository.class); userRepository = mock(UserRepository.class); projectService = mock(ProjectService.class); + modjamService = mock(ModjamService.class); service = new SitemapService( projectRepository, userRepository, projectService, + modjamService, new AppFrontendProperties("https://modtale.test") ); + when(modjamService.getAllJams()).thenReturn(List.of()); } @Test @@ -76,6 +84,25 @@ void generateSitemapFallsBackToAuthorLookupAndThenAuthorId() { assertTrue(xml.contains("https://modtale.test/creator/missing-id")); } + @Test + void generateSitemapIncludesVisibleJamsAndSkipsDrafts() { + Modjam active = jam("active-jam", "ACTIVE", "host-one", "2026-06-15T12:00:00Z"); + Modjam completed = jam("done-jam", "COMPLETED", "host-one", "2026-06-10T12:00:00Z"); + Modjam draft = jam("draft-jam", "DRAFT", "host-two", "2026-06-20T12:00:00Z"); + + when(projectRepository.findAllForSitemap()).thenReturn(List.of()); + when(modjamService.getAllJams()).thenReturn(List.of(active, completed, draft)); + + String xml = service.generateSitemap(); + + assertTrue(xml.contains("https://modtale.test/jams")); + assertTrue(xml.contains("https://modtale.test/jam/active-jam")); + assertTrue(xml.contains("2026-06-15")); + assertTrue(xml.contains("https://modtale.test/jam/done-jam")); + assertFalse(xml.contains("draft-jam")); + assertEquals(1, countOccurrences(xml, "https://modtale.test/creator/host-one")); + } + private static Project project(String id, String authorId, String author, String updatedAt) { Project project = new Project(); project.setId(id); @@ -85,6 +112,15 @@ private static Project project(String id, String authorId, String author, String return project; } + private static Modjam jam(String slug, String status, String hostName, String updatedAt) { + Modjam jam = new Modjam(); + jam.setSlug(slug); + jam.setStatus(status); + jam.setHostName(hostName); + jam.setUpdatedAt(Instant.parse(updatedAt)); + return jam; + } + private static int countOccurrences(String haystack, String needle) { int count = 0; int index = 0; diff --git a/frontend/cloudbuild.yml b/frontend/cloudbuild.yml index 0a589510..f01cab53 100644 --- a/frontend/cloudbuild.yml +++ b/frontend/cloudbuild.yml @@ -20,4 +20,3 @@ substitutions: options: defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET - \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83bf1ec9..276de4a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, Suspense, lazy, useRef } from 'react'; +import React, { useState, useEffect, useRef, Suspense, lazy } from 'react'; import { Route, Routes, useNavigate, useLocation, Navigate, BrowserRouter } from 'react-router-dom'; import { StaticRouter } from 'react-router-dom/server'; import { HelmetProvider } from 'react-helmet-async'; @@ -31,6 +31,8 @@ const Onboarding = lazy(() => import('@/modules/user/components/Onboarding').the const TermsOfService = lazy(() => import('@/modules/core/views/TermsOfService').then((module) => ({ default: module.TermsOfService }))); const PrivacyPolicy = lazy(() => import('@/modules/core/views/PrivacyPolicy').then((module) => ({ default: module.PrivacyPolicy }))); const Status = lazy(() => import('@/modules/core/views/Status').then((module) => ({ default: module.Status }))); +const JamsList = lazy(() => import('@/modules/jam/views/JamsList').then((module) => ({ default: module.JamsList }))); +const JamDetail = lazy(() => import('@/modules/jam/views/JamDetail').then((module) => ({ default: module.JamDetail }))); const UserProfile = lazy(() => import('@/modules/user/views/UserProfile').then((module) => ({ default: module.UserProfile }))); const Dashboard = lazy(() => import('@/modules/user/views/Dashboard').then((module) => ({ default: module.Dashboard }))); const VerifyEmail = lazy(() => import('@/modules/auth/views/VerifyEmail').then((module) => ({ default: module.VerifyEmail }))); @@ -65,6 +67,15 @@ const ScrollToTop = () => { useEffect(() => { const previousPath = previousPathRef.current; + const jamTabPattern = /^\/jam\/[^/]+\/(overview|rules|entries)$/; + const previousJamTabMatch = previousPath?.match(jamTabPattern); + const nextJamTabMatch = pathname.match(jamTabPattern); + const isSameJamTabTransition = Boolean( + previousPath + && previousJamTabMatch + && nextJamTabMatch + && previousPath.replace(/\/(overview|rules|entries)$/, '') === pathname.replace(/\/(overview|rules|entries)$/, '') + ); const previousProjectBase = previousPath ? projectRouteBase(previousPath) : ''; const nextProjectBase = projectRouteBase(pathname); const isSameProjectModalTransition = Boolean( @@ -76,7 +87,7 @@ const ScrollToTop = () => { previousPathRef.current = pathname; - if (isSameProjectModalTransition) { + if (isSameJamTabTransition || isSameProjectModalTransition) { return; } @@ -275,6 +286,9 @@ const AppContent: React.FC = () => { + } /> + } /> + } /> : diff --git a/frontend/src/index.css b/frontend/src/index.css index c999efe1..cfc511a7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -19,6 +19,7 @@ margin: 0; padding: 0; min-height: 100vh; + overflow-x: hidden; } #root { diff --git a/frontend/src/modules/core/components/Navbar.tsx b/frontend/src/modules/core/components/Navbar.tsx index 5df8a36f..9536438f 100644 --- a/frontend/src/modules/core/components/Navbar.tsx +++ b/frontend/src/modules/core/components/Navbar.tsx @@ -1,5 +1,5 @@ import React, { lazy, Suspense, useState, useRef, useEffect } from 'react'; -import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, Code2, ChevronDown, Layout, FileCode, Database, Palette, Save, Layers, LayoutGrid } from 'lucide-react'; +import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, Code2, ChevronDown, Layout, FileCode, Database, Palette, Save, Layers, LayoutGrid, Trophy } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { AnimatedThemeToggler } from '@/components/ui/AnimatedThemeToggler'; import { useMobile } from '@/context/MobileContext'; @@ -17,7 +17,7 @@ interface NavbarProps { onNavigate: (page: string) => void; isDarkMode: boolean; toggleDarkMode: () => void; - onUserClick: (username: string) => void; + onUserClick: (userId: string, username?: string) => void; } export const Navbar: React.FC = ({ @@ -97,6 +97,8 @@ export const Navbar: React.FC = ({ navigate(redirectTo || SiteRoutes.home(), { replace: true }); }; + const isJamPage = currentPage === 'jams' || currentPage.startsWith('jam/'); + return (