diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml index d1814df..8693f15 100644 --- a/gymboard-api/pom.xml +++ b/gymboard-api/pom.xml @@ -35,6 +35,12 @@ postgresql runtime + + org.apache.commons + commons-csv + 1.9.0 + + diff --git a/gymboard-api/sample_data/cities.csv b/gymboard-api/sample_data/cities.csv new file mode 100644 index 0000000..283f04b --- /dev/null +++ b/gymboard-api/sample_data/cities.csv @@ -0,0 +1,2 @@ +nl,groningen,Groningen +us,tampa,Tampa \ No newline at end of file diff --git a/gymboard-api/sample_data/countries.csv b/gymboard-api/sample_data/countries.csv new file mode 100644 index 0000000..0329caa --- /dev/null +++ b/gymboard-api/sample_data/countries.csv @@ -0,0 +1,3 @@ +us,United States +nl,Netherlands +de,Germany \ No newline at end of file diff --git a/gymboard-api/sample_data/exercises.csv b/gymboard-api/sample_data/exercises.csv new file mode 100644 index 0000000..a5bd31d --- /dev/null +++ b/gymboard-api/sample_data/exercises.csv @@ -0,0 +1,5 @@ +barbell-bench-press,Barbell Bench Press +barbell-squat,Barbell Squat +barbell-deadlift,Barbell Deadlift +barbell-overhead-press,Barbell Overhead Press +incline-dumbbell-bicep-curl,Incline Dumbbell Bicep Curl \ No newline at end of file diff --git a/gymboard-api/sample_data/gyms.csv b/gymboard-api/sample_data/gyms.csv new file mode 100644 index 0000000..5a3c4f9 --- /dev/null +++ b/gymboard-api/sample_data/gyms.csv @@ -0,0 +1,3 @@ +nl,groningen,trainmore-munnekeholm,Trainmore Munnekeholm,https://trainmore.nl/clubs/munnekeholm/,53.215939,6.561549,"Munnekeholm 1, 9711 JA Groningen" +nl,groningen,trainmore-oude-ebbinge,Trainmore Oude Ebbinge Non-Stop,https://trainmore.nl/clubs/oude-ebbinge/,53.2209,6.565976,Oude Ebbingestraat 54-58 +us,tampa,powerhouse-gym,Powerhouse Gym Athletic Club,http://www.pgathleticclub.com/,27.997223,-82.496237,"3251-A W Hillsborough Ave, Tampa, FL 33614, United States" \ No newline at end of file diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java new file mode 100644 index 0000000..a54e934 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java @@ -0,0 +1,30 @@ +package nl.andrewlalis.gymboard_api.controller; + +import jakarta.servlet.http.HttpServletResponse; +import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; +import nl.andrewlalis.gymboard_api.model.StoredFile; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@RestController +public class FileController { + private final StoredFileRepository fileRepository; + + public FileController(StoredFileRepository fileRepository) { + this.fileRepository = fileRepository; + } + + @GetMapping(path = "/files/{fileId}") + public void getFile(@PathVariable long fileId, HttpServletResponse response) throws IOException { + StoredFile file = fileRepository.findById(fileId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + response.setContentType(file.getMimeType()); + response.setContentLengthLong(file.getSize()); + response.getOutputStream().write(file.getContent()); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java index 0e0d4d0..576fb8d 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java @@ -1,11 +1,13 @@ package nl.andrewlalis.gymboard_api.controller; -import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; +import nl.andrewlalis.gymboard_api.controller.dto.*; import nl.andrewlalis.gymboard_api.service.GymService; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import nl.andrewlalis.gymboard_api.service.UploadService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; /** * Controller for accessing a particular gym. @@ -14,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}") public class GymController { private final GymService gymService; + private final UploadService uploadService; - public GymController(GymService gymService) { + public GymController(GymService gymService, UploadService uploadService) { this.gymService = gymService; + this.uploadService = uploadService; } @GetMapping @@ -25,6 +29,29 @@ public class GymController { @PathVariable String cityCode, @PathVariable String gymName ) { - return gymService.getGym(countryCode, cityCode, gymName); + return gymService.getGym(new RawGymId(countryCode, cityCode, gymName)); + } + + @PostMapping(path = "/submissions") + public ExerciseSubmissionResponse createSubmission( + @PathVariable String countryCode, + @PathVariable String cityCode, + @PathVariable String gymName, + @RequestBody ExerciseSubmissionPayload payload + ) throws IOException { + return gymService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload); + } + + @PostMapping( + path = "/submissions/upload", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public UploadedFileResponse uploadVideo( + @PathVariable String countryCode, + @PathVariable String cityCode, + @PathVariable String gymName, + @RequestParam MultipartFile file + ) throws IOException { + return uploadService.handleUpload(new RawGymId(countryCode, cityCode, gymName), file); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseResponse.java new file mode 100644 index 0000000..650cf29 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseResponse.java @@ -0,0 +1,12 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +import nl.andrewlalis.gymboard_api.model.exercise.Exercise; + +public record ExerciseResponse( + String shortName, + String displayName +) { + public ExerciseResponse(Exercise exercise) { + this(exercise.getShortName(), exercise.getDisplayName()); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java new file mode 100644 index 0000000..a8ef574 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java @@ -0,0 +1,8 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record ExerciseSubmissionPayload( + String name, + String exerciseShortName, + float weight, + long videoId +) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java new file mode 100644 index 0000000..cd44377 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java @@ -0,0 +1,29 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; + +import java.time.format.DateTimeFormatter; + +public record ExerciseSubmissionResponse( + long id, + String createdAt, + GymSimpleResponse gym, + ExerciseResponse exercise, + String submitterName, + boolean verified, + double weight, + String videoFileUrl +) { + public ExerciseSubmissionResponse(ExerciseSubmission submission) { + this( + submission.getId(), + submission.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + new GymSimpleResponse(submission.getGym()), + new ExerciseResponse(submission.getExercise()), + submission.getSubmitterName(), + submission.isVerified(), + submission.getWeight().doubleValue(), + "bleh" + ); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/GymSimpleResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/GymSimpleResponse.java new file mode 100644 index 0000000..713a800 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/GymSimpleResponse.java @@ -0,0 +1,19 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +import nl.andrewlalis.gymboard_api.model.Gym; + +public record GymSimpleResponse( + String countryCode, + String cityShortName, + String shortName, + String displayName +) { + public GymSimpleResponse(Gym gym) { + this( + gym.getCity().getCountry().getCode(), + gym.getCity().getShortName(), + gym.getShortName(), + gym.getDisplayName() + ); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UploadedFileResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UploadedFileResponse.java new file mode 100644 index 0000000..b3f3e0b --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UploadedFileResponse.java @@ -0,0 +1,3 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record UploadedFileResponse(long id) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/CityRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/CityRepository.java index bb3714d..3d8661a 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/CityRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/CityRepository.java @@ -3,8 +3,13 @@ package nl.andrewlalis.gymboard_api.dao; import nl.andrewlalis.gymboard_api.model.City; import nl.andrewlalis.gymboard_api.model.CityId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface CityRepository extends JpaRepository { + @Query("SELECT c FROM City c WHERE c.id.shortName = :shortName AND c.id.country.code = :countryCode") + Optional findByShortNameAndCountryCode(String shortName, String countryCode); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/StoredFileRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/StoredFileRepository.java new file mode 100644 index 0000000..0c1cfe0 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/StoredFileRepository.java @@ -0,0 +1,9 @@ +package nl.andrewlalis.gymboard_api.dao; + +import nl.andrewlalis.gymboard_api.model.StoredFile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface StoredFileRepository extends JpaRepository { +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java new file mode 100644 index 0000000..60ecc7d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java @@ -0,0 +1,9 @@ +package nl.andrewlalis.gymboard_api.dao.exercise; + +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExerciseSubmissionRepository extends JpaRepository { +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java index 6615dd8..672f7b2 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java @@ -5,6 +5,8 @@ import nl.andrewlalis.gymboard_api.dao.CountryRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.model.exercise.Exercise; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; @@ -12,11 +14,16 @@ import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.io.FileReader; import java.io.IOException; import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.function.Consumer; +/** + * Simple component that loads sample data that's useful when testing the application. + */ @Component public class SampleDataLoader implements ApplicationListener { private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class); @@ -43,8 +50,8 @@ public class SampleDataLoader implements ApplicationListener { + exerciseRepository.save(new Exercise(record.get(0), record.get(1))); + }); + loadCsv("countries", record -> { + countryRepository.save(new Country(record.get(0), record.get(1))); + }); + loadCsv("cities", record -> { + var country = countryRepository.findById(record.get(0)).orElseThrow(); + cityRepository.save(new City(record.get(1), record.get(2), country)); + }); + loadCsv("gyms", record -> { + var city = cityRepository.findByShortNameAndCountryCode(record.get(1), record.get(0)).orElseThrow(); + gymRepository.save(new Gym( + city, + record.get(2), + record.get(3), + record.get(4), + new GeoPoint( + new BigDecimal(record.get(5)), + new BigDecimal(record.get(6)) + ), + record.get(7) + )); + }); + } - Country nl = countryRepository.save(new Country("nl", "Netherlands")); - City groningen = cityRepository.save(new City("groningen", "Groningen", nl)); - Gym g1 = gymRepository.save(new Gym( - groningen, - "trainmore-munnekeholm", - "Trainmore Munnekeholm", - "https://trainmore.nl/clubs/munnekeholm/", - new GeoPoint(new BigDecimal("53.215939"), new BigDecimal("6.561549")), - "Munnekeholm 1, 9711 JA Groningen" - )); - Gym g2 = gymRepository.save(new Gym( - groningen, - "trainmore-oude-ebbinge", - "Trainmore Oude Ebbinge Non-Stop", - "https://trainmore.nl/clubs/oude-ebbinge/", - new GeoPoint(new BigDecimal("53.220900"), new BigDecimal("6.565976")), - "Oude Ebbingestraat 54-58, 9712 HL Groningen" - )); - - - Country us = countryRepository.save(new Country("us", "United States")); - City tampa = cityRepository.save(new City("tampa", "Tampa", us)); - Gym g3 = gymRepository.save(new Gym( - tampa, - "powerhouse-gym", - "Powerhouse Gym Athletic Club", - "http://www.pgathleticclub.com/", - new GeoPoint(new BigDecimal("27.997223"), new BigDecimal("-82.496237")), - "3251-A W Hillsborough Ave, Tampa, FL 33614, United States" - )); + private void loadCsv(String csvName, Consumer recordConsumer) throws IOException { + var reader = new FileReader("sample_data/" + csvName + ".csv"); + for (var record : CSVFormat.DEFAULT.parse(reader)) { + recordConsumer.accept(record); + } } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/StoredFile.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/StoredFile.java new file mode 100644 index 0000000..6154a00 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/StoredFile.java @@ -0,0 +1,68 @@ +package nl.andrewlalis.gymboard_api.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * Base class for file storage. Files (mostly gym videos) are stored in the + * database as blobs, after they've been pre-processed with compression and/or + * resizing. + */ +@Entity +@Table(name = "stored_file") +public class StoredFile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Column(nullable = false, updatable = false) + private String filename; + + @Column(nullable = false, updatable = false) + private String mimeType; + + @Column(nullable = false, updatable = false) + private long size; + + @Lob + @Column(nullable = false, updatable = false) + private byte[] content; + + public StoredFile() {} + + public StoredFile(String filename, String mimeType, long size, byte[] content) { + this.filename = filename; + this.mimeType = mimeType; + this.size = size; + this.content = content; + } + + public Long getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public String getFilename() { + return filename; + } + + public String getMimeType() { + return mimeType; + } + + public long getSize() { + return size; + } + + public byte[] getContent() { + return content; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/Exercise.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/Exercise.java index 3b69c50..1415570 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/Exercise.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/Exercise.java @@ -13,23 +13,23 @@ import jakarta.persistence.Table; public class Exercise { @Id @Column(nullable = false, length = 127) - private String shortname; + private String shortName; @Column(nullable = false, unique = true) - private String name; + private String displayName; public Exercise() {} - public Exercise(String shortname, String name) { - this.shortname = shortname; - this.name = name; + public Exercise(String shortName, String displayName) { + this.shortName = shortName; + this.displayName = displayName; } - public String getShortname() { - return shortname; + public String getShortName() { + return shortName; } - public String getName() { - return name; + public String getDisplayName() { + return displayName; } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java index d0c2c07..ce3e9f4 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java @@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.model.exercise; import jakarta.persistence.*; import nl.andrewlalis.gymboard_api.model.Gym; +import nl.andrewlalis.gymboard_api.model.StoredFile; import org.hibernate.annotations.CreationTimestamp; import java.math.BigDecimal; @@ -31,4 +32,49 @@ public class ExerciseSubmission { @Column(nullable = false, precision = 7, scale = 2) private BigDecimal weight; + + @OneToOne(optional = false, fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private StoredFile videoFile; + + public ExerciseSubmission() {} + + public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal weight, StoredFile videoFile) { + this.gym = gym; + this.exercise = exercise; + this.submitterName = submitterName; + this.weight = weight; + this.videoFile = videoFile; + } + + public Long getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public Gym getGym() { + return gym; + } + + public Exercise getExercise() { + return exercise; + } + + public String getSubmitterName() { + return submitterName; + } + + public boolean isVerified() { + return verified; + } + + public BigDecimal getWeight() { + return weight; + } + + public StoredFile getVideoFile() { + return videoFile; + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java index 3aff7f7..69ed144 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java @@ -1,25 +1,69 @@ package nl.andrewlalis.gymboard_api.service; +import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; +import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; +import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; import nl.andrewlalis.gymboard_api.dao.GymRepository; +import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; +import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; +import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository; import nl.andrewlalis.gymboard_api.model.Gym; +import nl.andrewlalis.gymboard_api.model.StoredFile; +import nl.andrewlalis.gymboard_api.model.exercise.Exercise; +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; + @Service public class GymService { private final GymRepository gymRepository; + private final StoredFileRepository fileRepository; + private final ExerciseRepository exerciseRepository; + private final ExerciseSubmissionRepository exerciseSubmissionRepository; - public GymService(GymRepository gymRepository) { + public GymService(GymRepository gymRepository, StoredFileRepository fileRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionRepository exerciseSubmissionRepository) { this.gymRepository = gymRepository; + this.fileRepository = fileRepository; + this.exerciseRepository = exerciseRepository; + this.exerciseSubmissionRepository = exerciseSubmissionRepository; } @Transactional(readOnly = true) - public GymResponse getGym(String countryCode, String city, String gymName) { - Gym gym = gymRepository.findByRawId(gymName, city, countryCode) + public GymResponse getGym(RawGymId id) { + Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); return new GymResponse(gym); } + + @Transactional + public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) throws IOException { + Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Exercise exercise = exerciseRepository.findById(payload.exerciseShortName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + // TODO: Implement legitimate file storage. + Path path = Path.of("sample_data", "sample_curl_14kg.MP4"); + StoredFile file = fileRepository.save(new StoredFile( + "sample_curl_14kg.MP4", + "video/mp4", + Files.size(path), + Files.readAllBytes(path) + )); + ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission( + gym, + exercise, + payload.name(), + BigDecimal.valueOf(payload.weight()), + file + )); + return new ExerciseSubmissionResponse(submission); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java new file mode 100644 index 0000000..b79dbf3 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java @@ -0,0 +1,68 @@ +package nl.andrewlalis.gymboard_api.service; + +import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; +import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse; +import nl.andrewlalis.gymboard_api.dao.GymRepository; +import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; +import nl.andrewlalis.gymboard_api.model.Gym; +import nl.andrewlalis.gymboard_api.model.StoredFile; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.FileSystemUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + + +/** + * Service for handling large file uploads. + * TODO: Use this instead of simple multipart form data. + */ +@Service +public class UploadService { + private final StoredFileRepository fileRepository; + private final GymRepository gymRepository; + + public UploadService(StoredFileRepository fileRepository, GymRepository gymRepository) { + this.fileRepository = fileRepository; + this.gymRepository = gymRepository; + } + + @Transactional + public UploadedFileResponse handleUpload(RawGymId gymId, MultipartFile multipartFile) throws IOException { + Gym gym = gymRepository.findByRawId(gymId.gymName(), gymId.cityCode(), gymId.countryCode()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + // TODO: Check that user is allowed to upload. + // TODO: Robust file type check. + if (!"video/mp4".equalsIgnoreCase(multipartFile.getContentType())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid content type."); + } + Path tempDir = Files.createTempDirectory("gymboard-file-upload"); + Path tempFile = tempDir.resolve("video-file"); + multipartFile.transferTo(tempFile); + Process ffmpegProcess = new ProcessBuilder() + .command("ffmpeg", "-i", "video-file", "-vf", "scale=640x480:flags=lanczos", "-vcodec", "libx264", "-crf", "28", "output.mp4") + .inheritIO() + .directory(tempDir.toFile()) + .start(); + try { + int result = ffmpegProcess.waitFor(); + if (result != 0) throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg exited with code " + result); + } catch (InterruptedException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg process interrupted", e); + } + Path compressedFile = tempDir.resolve("output.mp4"); + StoredFile file = fileRepository.save(new StoredFile( + "compressed.mp4", + "video/mp4", + Files.size(compressedFile), + Files.readAllBytes(compressedFile) + )); + FileSystemUtils.deleteRecursively(tempDir); + return new UploadedFileResponse(file.getId()); + } +} diff --git a/gymboard-api/src/main/resources/application.properties b/gymboard-api/src/main/resources/application.properties index 06653f8..45044f6 100644 --- a/gymboard-api/src/main/resources/application.properties +++ b/gymboard-api/src/main/resources/application.properties @@ -1,3 +1,6 @@ spring.jpa.open-in-view=false -spring.servlet.multipart.enabled=false +# TODO: Find a better way than dumping files into memory. +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=1GB +spring.servlet.multipart.max-request-size=2GB diff --git a/gymboard-app/src/api/gymboard-api.ts b/gymboard-app/src/api/gymboard-api.ts index eda8572..d81b566 100644 --- a/gymboard-app/src/api/gymboard-api.ts +++ b/gymboard-app/src/api/gymboard-api.ts @@ -1,11 +1,30 @@ -import axios from "axios"; -import process from "process"; +import axios from 'axios'; +export const BASE_URL = 'http://localhost:8080'; + +// TODO: Figure out how to get the base URL from environment. const api = axios.create({ - baseURL: 'http://localhost:8080' + baseURL: BASE_URL }); -export type Gym = { +export interface Exercise { + shortName: string, + displayName: string +}; + +export interface GeoPoint { + latitude: number, + longitude: number +}; + +export interface ExerciseSubmissionPayload { + name: string, + exerciseShortName: string, + weight: number, + videoId: number +}; + +export interface Gym { countryCode: string, countryName: string, cityShortName: string, @@ -14,13 +33,18 @@ export type Gym = { shortName: string, displayName: string, websiteUrl: string | null, - location: { - latitude: number, - longitude: number - }, + location: GeoPoint, streetAddress: string }; +export function getUploadUrl(gym: Gym) { + return BASE_URL + `/gyms/${gym.countryCode}/${gym.cityShortName}/${gym.shortName}/submissions/upload`; +} + +export function getFileUrl(fileId: number) { + return BASE_URL + `/files/${fileId}`; +} + export async function getGym(countryCode: string, cityShortName: string, gymShortName: string): Promise { const response = await api.get(`/gyms/${countryCode}/${cityShortName}/${gymShortName}`); const d = response.data; @@ -37,4 +61,9 @@ export async function getGym(countryCode: string, cityShortName: string, gymShor streetAddress: d.streetAddress }; return gym; -} \ No newline at end of file +} + +export async function getExercises(): Promise> { + const response = await api.get(`/exercises`); + return response.data; +} diff --git a/gymboard-app/src/pages/GymPage.vue b/gymboard-app/src/pages/GymPage.vue index 4e60860..e08e09b 100644 --- a/gymboard-app/src/pages/GymPage.vue +++ b/gymboard-app/src/pages/GymPage.vue @@ -1,13 +1,44 @@