diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java index 9fe8f23..5b47b50 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java @@ -1,5 +1,6 @@ package nl.andrewlalis.gymboard_api.config; +import nl.andrewlalis.gymboard_api.domains.api.dto.ApiValidationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -33,6 +34,9 @@ public class ErrorResponseHandler { } } responseContent.put("message", message); + if (e instanceof ApiValidationException validationException) { + responseContent.put("validation_messages", validationException.getValidationResponse().getMessages()); + } return ResponseEntity.status(e.getStatusCode()).body(responseContent); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 9c84819..78af6c7 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -55,8 +55,6 @@ public class SecurityConfig { ).permitAll() .requestMatchers(// Allow the following POST endpoints to be public. HttpMethod.POST, - "/gyms/*/submissions", - "/gyms/*/submissions/upload", "/auth/token", "/auth/register", "/auth/activate", diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java index 6ec6652..4ca698c 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java @@ -1,5 +1,8 @@ package nl.andrewlalis.gymboard_api.config; +import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient; +import nl.andrewlalis.gymboard_api.util.ULID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -11,4 +14,17 @@ public class WebComponents { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(10); } + + @Bean + public ULID ulid() { + return new ULID(); + } + + @Value("${app.cdn-origin}") + private String cdnOrigin; + + @Bean + public CdnClient cdnClient() { + return new CdnClient(cdnOrigin); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java deleted file mode 100644 index f78f351..0000000 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package nl.andrewlalis.gymboard_api.config; - -import nl.andrewlalis.gymboard_api.util.ULID; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class WebConfig { - @Bean - public ULID ulid() { - return new ULID(); - } -} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ApiValidationException.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ApiValidationException.java new file mode 100644 index 0000000..587ae51 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ApiValidationException.java @@ -0,0 +1,17 @@ +package nl.andrewlalis.gymboard_api.domains.api.dto; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class ApiValidationException extends ResponseStatusException { + private final ValidationResponse validationResponse; + + public ApiValidationException(ValidationResponse validationResponse) { + super(HttpStatus.BAD_REQUEST, "Validation failed."); + this.validationResponse = validationResponse; + } + + public ValidationResponse getValidationResponse() { + return validationResponse; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ValidationResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ValidationResponse.java new file mode 100644 index 0000000..001b184 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ValidationResponse.java @@ -0,0 +1,22 @@ +package nl.andrewlalis.gymboard_api.domains.api.dto; + +import java.util.ArrayList; +import java.util.List; + +public class ValidationResponse { + private boolean valid = true; + private List messages = new ArrayList<>(); + + public void addMessage(String message) { + this.messages.add(message); + this.valid = false; + } + + public boolean isValid() { + return valid; + } + + public List getMessages() { + return messages; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java index 08681a6..a2f2cec 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java @@ -3,13 +3,13 @@ package nl.andrewlalis.gymboard_api.domains.api.service.submission; import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository; -import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId; -import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionPayload; -import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse; +import nl.andrewlalis.gymboard_api.domains.api.dto.*; import nl.andrewlalis.gymboard_api.domains.api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; import nl.andrewlalis.gymboard_api.domains.api.model.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission; +import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient; +import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.UploadsClient; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.model.User; import nl.andrewlalis.gymboard_api.util.ULID; @@ -36,16 +36,18 @@ public class ExerciseSubmissionService { private final ExerciseRepository exerciseRepository; private final SubmissionRepository submissionRepository; private final ULID ulid; + private final CdnClient cdnClient; public ExerciseSubmissionService(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, SubmissionRepository submissionRepository, - ULID ulid) { + ULID ulid, CdnClient cdnClient) { this.gymRepository = gymRepository; this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; this.submissionRepository = submissionRepository; this.ulid = ulid; + this.cdnClient = cdnClient; } @Transactional(readOnly = true) @@ -64,16 +66,22 @@ public class ExerciseSubmissionService { */ @Transactional public SubmissionResponse createSubmission(CompoundGymId id, String userId, SubmissionPayload payload) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN)); Gym gym = gymRepository.findByCompoundId(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN)); + if (!user.isActivated()) throw new ResponseStatusException(HttpStatus.FORBIDDEN); Exercise exercise = exerciseRepository.findById(payload.exerciseShortName()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid exercise.")); - // TODO: Validate the submission data. + var validationResponse = validateSubmissionData(gym, user, exercise, payload); + if (!validationResponse.isValid()) { + throw new ApiValidationException(validationResponse); + } // Create the submission. + LocalDateTime performedAt = payload.performedAt(); + if (performedAt == null) performedAt = LocalDateTime.now(); BigDecimal rawWeight = BigDecimal.valueOf(payload.weight()); WeightUnit weightUnit = WeightUnit.parse(payload.weightUnit()); BigDecimal metricWeight = BigDecimal.valueOf(payload.weight()); @@ -81,17 +89,47 @@ public class ExerciseSubmissionService { metricWeight = WeightUnit.toKilograms(rawWeight); } Submission submission = submissionRepository.saveAndFlush(new Submission( - ulid.nextULID(), - gym, - exercise, - user, - LocalDateTime.now(), + ulid.nextULID(), gym, exercise, user, + performedAt, payload.videoFileId(), - rawWeight, - weightUnit, - metricWeight, - payload.reps() + rawWeight, weightUnit, metricWeight, payload.reps() )); return new SubmissionResponse(submission); } + + private ValidationResponse validateSubmissionData(Gym gym, User user, Exercise exercise, SubmissionPayload data) { + ValidationResponse response = new ValidationResponse(); + LocalDateTime cutoff = LocalDateTime.now().minusDays(3); + if (data.performedAt() != null && data.performedAt().isAfter(LocalDateTime.now())) { + response.addMessage("Cannot submit an exercise from the future."); + } + if (data.performedAt() != null && data.performedAt().isBefore(cutoff)) { + response.addMessage("Cannot submit an exercise too far in the past."); + } + if (data.reps() < 1 || data.reps() > 500) { + response.addMessage("Invalid rep count."); + } + BigDecimal rawWeight = BigDecimal.valueOf(data.weight()); + WeightUnit weightUnit = WeightUnit.parse(data.weightUnit()); + BigDecimal metricWeight = WeightUnit.toKilograms(rawWeight, weightUnit); + + if (metricWeight.compareTo(BigDecimal.ZERO) <= 0 || metricWeight.compareTo(BigDecimal.valueOf(1000.0)) > 0) { + response.addMessage("Invalid weight."); + } + + try { + UploadsClient.FileMetadataResponse metadata = cdnClient.uploads.getFileMetadata(data.videoFileId()); + if (metadata == null) { + response.addMessage("Missing video file."); + } else if (!metadata.availableForDownload()) { + response.addMessage("File not yet available for download."); + } else if (!"video/mp4".equals(metadata.mimeType())) { + response.addMessage("Invalid video file format."); + } + } catch (Exception e) { + log.error("Error fetching file metadata.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error fetching uploaded video file metadata."); + } + return response; + } } diff --git a/gymboard-app/src/api/main/submission.ts b/gymboard-app/src/api/main/submission.ts index 3818e72..edce481 100644 --- a/gymboard-app/src/api/main/submission.ts +++ b/gymboard-app/src/api/main/submission.ts @@ -4,12 +4,12 @@ import { api } from 'src/api/main/index'; import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing'; import { DateTime } from 'luxon'; import {User} from 'src/api/main/auth'; +import {AuthStoreType} from 'stores/auth-store'; /** * The data that's sent when creating a submission. */ export interface ExerciseSubmissionPayload { - name: string; exerciseShortName: string; weight: number; weightUnit: string; @@ -59,10 +59,11 @@ class SubmissionsModule { public async createSubmission( gym: GymRoutable, - payload: ExerciseSubmissionPayload + payload: ExerciseSubmissionPayload, + authStore: AuthStoreType ): Promise { const gymId = getGymCompoundId(gym); - const response = await api.post(`/gyms/${gymId}/submissions`, payload); + const response = await api.post(`/gyms/${gymId}/submissions`, payload, authStore.axiosConfig); return parseSubmission(response.data); } } diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index 765bf9f..04e5c38 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -31,13 +31,18 @@ export default { recentLifts: 'Recent Lifts', }, submitPage: { - name: 'Your Name', + loginToSubmit: 'Login or register to submit your lift', exercise: 'Exercise', weight: 'Weight', reps: 'Repetitions', date: 'Date', upload: 'Video File to Upload', submit: 'Submit', + submitUploading: 'Uploading video...', + submitCreatingSubmission: 'Creating submission...', + submitVideoProcessing: 'Processing...', + submitComplete: 'Submission complete!', + submitFailed: 'Submission processing failed. Please try again later.', }, }, userPage: { diff --git a/gymboard-app/src/i18n/nl-NL/index.ts b/gymboard-app/src/i18n/nl-NL/index.ts index d83c6f4..11d3e72 100644 --- a/gymboard-app/src/i18n/nl-NL/index.ts +++ b/gymboard-app/src/i18n/nl-NL/index.ts @@ -31,7 +31,7 @@ export default { recentLifts: 'Recente liften', }, submitPage: { - name: 'Jouw naam', + loginToSubmit: 'Log in of meld je aan om je lift te indienen', exercise: 'Oefening', weight: 'Gewicht', reps: 'Repetities', diff --git a/gymboard-app/src/pages/gym/GymSubmissionPage.vue b/gymboard-app/src/pages/gym/GymSubmissionPage.vue index 3d79498..2787dd6 100644 --- a/gymboard-app/src/pages/gym/GymSubmissionPage.vue +++ b/gymboard-app/src/pages/gym/GymSubmissionPage.vue @@ -11,17 +11,10 @@ A high-level overview of the submission process is as follows: 5. We wait on the submission page until the submission is done processing, then show a message and navigate to the submission page. --> diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/WeightedWildcardQueryBuilder.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/WeightedWildcardQueryBuilder.java index 6e795c9..ffa6462 100644 --- a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/WeightedWildcardQueryBuilder.java +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/WeightedWildcardQueryBuilder.java @@ -23,7 +23,9 @@ public class WeightedWildcardQueryBuilder { } public Optional build(String rawSearchQuery) { - if (rawSearchQuery == null || rawSearchQuery.isBlank()) return Optional.empty(); + if (rawSearchQuery == null) return Optional.empty(); + rawSearchQuery = rawSearchQuery.replaceAll("\\*", ""); + if (rawSearchQuery.isBlank()) return Optional.empty(); String[] terms = rawSearchQuery.toLowerCase().split("\\s+"); for (String term : terms) { String searchTerm = term + "*";