├── .tool-versions ├── system.properties ├── settings.gradle.kts ├── .env.example ├── Procfile ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── resources │ │ ├── static │ │ │ ├── favicon.ico │ │ │ ├── img │ │ │ │ └── landing │ │ │ │ │ ├── typo-form-screen.png │ │ │ │ │ └── typo-info-screen.png │ │ │ ├── widget │ │ │ │ └── typo-modal.js │ │ │ └── fragments │ │ │ │ ├── time-converter.js │ │ │ │ └── lang-switcher.js │ │ ├── templates │ │ │ ├── error-general.html │ │ │ ├── widget │ │ │ │ ├── report-typo-error.html │ │ │ │ ├── report-typo-success.html │ │ │ │ └── typo-form.html │ │ │ ├── layout.html │ │ │ ├── about.html │ │ │ ├── fragments │ │ │ │ ├── header.html │ │ │ │ ├── workspace.html │ │ │ │ └── footer.html │ │ │ ├── workspace │ │ │ │ └── wks-integration.html │ │ │ ├── login.html │ │ │ ├── workspaces.html │ │ │ ├── create-workspace.html │ │ │ └── account │ │ │ │ ├── acc-info.html │ │ │ │ ├── pass-update.html │ │ │ │ ├── prof-update.html │ │ │ │ └── signup.html │ │ ├── db │ │ │ └── changelog │ │ │ │ ├── changesets │ │ │ │ ├── 2021 │ │ │ │ │ └── 04 │ │ │ │ │ │ ├── 20212304094515440-add-column-api-access-token-to-typo.xml │ │ │ │ │ │ ├── 20211904104233167-create-workspace-table.xml │ │ │ │ │ │ └── 20211904104243755-create-typo-table.xml │ │ │ │ ├── 2022 │ │ │ │ │ ├── 11 │ │ │ │ │ │ ├── 2022-11-03-add-unique-constraint-to-name-workspace.xml │ │ │ │ │ │ └── 2022-11-04-add-column-url-to-workspace.xml │ │ │ │ │ ├── 12 │ │ │ │ │ │ ├── 2022-12-01-add-workspace_role-table.xml │ │ │ │ │ │ └── 2022-12-08-create-workspace-settings-table.xml │ │ │ │ │ └── 04 │ │ │ │ │ │ └── 20220428163604-create-account-table.xml │ │ │ │ ├── 2023 │ │ │ │ │ └── 02 │ │ │ │ │ │ └── 2023-20-02-remove-many-to-one-account-workspace-relation.xml │ │ │ │ ├── 2024 │ │ │ │ │ └── 05 │ │ │ │ │ │ ├── 2024-05-31-remove-unique-constraint-to-name-workspace.xml │ │ │ │ │ │ └── 2024-05-01-add-workspace-urls-table.xml │ │ │ │ └── 2025 │ │ │ │ │ └── 06 │ │ │ │ │ └── 2025-06-31-add-yandex-to-auth-provider.xml │ │ │ │ └── db.changelog-master.xml │ │ └── config │ │ │ ├── application-dev.yml │ │ │ ├── application-prod.yml │ │ │ └── application.yml │ └── java │ │ └── io │ │ └── hexlet │ │ └── typoreporter │ │ ├── domain │ │ ├── Identifiable.java │ │ ├── workspace │ │ │ ├── AccountRole.java │ │ │ ├── constraint │ │ │ │ ├── WorkspaceDescription.java │ │ │ │ ├── WorkspaceName.java │ │ │ │ └── WorkspaceUrl.java │ │ │ ├── WorkspaceRoleId.java │ │ │ ├── WorkspaceRole.java │ │ │ └── AllowedUrl.java │ │ ├── typo │ │ │ ├── TypoEvent.java │ │ │ ├── constraint │ │ │ │ ├── ReporterComment.java │ │ │ │ ├── TextTypo.java │ │ │ │ ├── ReporterName.java │ │ │ │ ├── TextBeforeTypo.java │ │ │ │ ├── TypoPageUrl.java │ │ │ │ └── TextAfterTypo.java │ │ │ ├── InvalidTypoEventException.java │ │ │ ├── TypoStatus.java │ │ │ └── Typo.java │ │ ├── account │ │ │ ├── AuthProvider.java │ │ │ └── constraint │ │ │ │ ├── AccountPassword.java │ │ │ │ └── AccountUsername.java │ │ ├── AbstractAuditingEntity.java │ │ └── workspacesettings │ │ │ └── WorkspaceSettings.java │ │ ├── service │ │ ├── QueryAccount.java │ │ ├── dto │ │ │ ├── workspace │ │ │ │ ├── AllowedUrlDTO.java │ │ │ │ ├── WorkspaceRoleInfo.java │ │ │ │ ├── WorkspaceInfo.java │ │ │ │ └── CreateWorkspace.java │ │ │ ├── account │ │ │ │ ├── InfoAccount.java │ │ │ │ ├── UpdatePassword.java │ │ │ │ ├── UpdateProfile.java │ │ │ │ └── CustomUserDetails.java │ │ │ ├── typo │ │ │ │ ├── UpdateTypoEvent.java │ │ │ │ ├── ReportedTypo.java │ │ │ │ ├── TypoInfo.java │ │ │ │ └── TypoReport.java │ │ │ ├── FieldMatchConsiderCaseValidator.java │ │ │ ├── FieldMatchIgnoreCaseValidator.java │ │ │ ├── FieldMatchConsiderCase.java │ │ │ ├── AbstractFieldMatchValidator.java │ │ │ └── FieldMatchIgnoreCase.java │ │ ├── oauth2 │ │ │ ├── OAuth2UserInfo.java │ │ │ ├── OAuth2UserInfoFactory.java │ │ │ ├── CustomOAuth2User.java │ │ │ ├── YandexOAuth2UserInfo.java │ │ │ ├── GithubOAuth2UserInfo.java │ │ │ └── OAuth2Service.java │ │ ├── account │ │ │ ├── signup │ │ │ │ ├── SignupAccountMapper.java │ │ │ │ ├── SignupAccountUseCase.java │ │ │ │ └── SignupAccount.java │ │ │ ├── EmailAlreadyExistException.java │ │ │ └── UsernameAlreadyExistException.java │ │ ├── mapper │ │ │ ├── AccountMapper.java │ │ │ ├── WorkspaceRoleMapper.java │ │ │ ├── WorkspaceMapper.java │ │ │ └── TypoMapper.java │ │ └── WorkspaceRoleService.java │ │ ├── handler │ │ ├── exception │ │ │ ├── ConvertibleToFieldError.java │ │ │ ├── AllowedUrlNotFoundException.java │ │ │ ├── AllowedUrlAlreadyExistException.java │ │ │ ├── TypoNotFoundException.java │ │ │ ├── AccountNotFoundException.java │ │ │ ├── ForbiddenDomainException.java │ │ │ ├── WorkspaceRoleNotFoundException.java │ │ │ ├── OldPasswordWrongException.java │ │ │ ├── NewPasswordTheSameException.java │ │ │ ├── WorkspaceNotFoundException.java │ │ │ ├── AccountAlreadyExistException.java │ │ │ └── WorkspaceAlreadyExistException.java │ │ └── GlobalExceptionHandler.java │ │ ├── web │ │ ├── model │ │ │ ├── AccountModelMapper.java │ │ │ ├── WorkspaceUserModel.java │ │ │ └── SignupAccountModel.java │ │ └── WorkspaceApi.java │ │ ├── utils │ │ ├── ValidationEmail.java │ │ └── TextUtils.java │ │ ├── repository │ │ ├── WorkspaceSettingsRepository.java │ │ ├── AccountRepository.java │ │ ├── AllowedUrlRepository.java │ │ ├── WorkspaceRepository.java │ │ ├── WorkspaceRoleRepository.java │ │ └── TypoRepository.java │ │ ├── config │ │ ├── WebConfig.java │ │ ├── CustomAccessDeniedHandler.java │ │ ├── audit │ │ │ └── AuditConfiguration.java │ │ ├── thymeleaf │ │ │ └── ThymeleafAutoConfiguration.java │ │ ├── CookieLocaleResolver.java │ │ ├── I18nConfig.java │ │ └── DynamicCorsConfigurationSource.java │ │ ├── controller │ │ ├── IndexController.java │ │ ├── TypoController.java │ │ └── WidgetController.java │ │ ├── security │ │ └── service │ │ │ ├── SecuredWorkspaceService.java │ │ │ └── AccountDetailService.java │ │ └── HexletTypoReporter.java └── test │ ├── java │ └── io │ │ └── hexlet │ │ └── typoreporter │ │ ├── test │ │ ├── Constraints.java │ │ ├── DBUnitEnumPostgres.java │ │ └── asserts │ │ │ ├── TypoAssert.java │ │ │ ├── TypoInfoAssert.java │ │ │ └── ReportedTypoAssert.java │ │ ├── service │ │ ├── mapper │ │ │ ├── TypoToTypoInfoMapperTest.java │ │ │ ├── TypoReportToTypoMapperTest.java │ │ │ ├── TypoToReportedTypoMapperTest.java │ │ │ ├── WorkspaceToWorkspaceInfoTest.java │ │ │ └── CreateWorkspaceToWorkspaceTest.java │ │ └── WorkspaceSettingsServiceIT.java │ │ ├── TypoReporterApplicationIT.java │ │ ├── repository │ │ ├── AccountRepositoryIT.java │ │ └── WorkspaceRoleRepositoryIT.java │ │ └── domain │ │ └── EntityTest.java │ └── resources │ ├── datasets │ ├── workspaceRoles.yml │ ├── allowedUrls.yml │ ├── workspace_settings.yml │ ├── workspaces.yml │ └── accounts.yml │ └── config │ └── application.yml ├── Dockerfile ├── docker └── docker-compose.yml ├── .editorconfig ├── .github ├── workflows │ ├── github-ci.yml │ └── pages.yml └── ISSUE_TEMPLATE │ ├── task.yml │ ├── featureRequest.yml │ └── bugreport.yml ├── Makefile ├── Vagrantfile ├── vagrant_provision.sh ├── docs └── requirements.yml └── gradlew.bat /.tool-versions: -------------------------------------------------------------------------------- 1 | java openjdk-19.0.2 2 | -------------------------------------------------------------------------------- /system.properties: -------------------------------------------------------------------------------- 1 | java.runtime.version=21 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "typoreporter" 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_CLIENT_ID= 2 | GITHUB_CLIENT_SECRET= 3 | YANDEX_CLIENT_ID= 4 | YANDEX_CLIENT_SECRET= 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java -Xmx256m -jar build/libs/typoreporter-*.jar --spring.profiles.active=default,prod --server.port=$PORT 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hexlet/hexlet-correction/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hexlet/hexlet-correction/HEAD/src/main/resources/static/favicon.ico -------------------------------------------------------------------------------- /src/main/resources/static/img/landing/typo-form-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hexlet/hexlet-correction/HEAD/src/main/resources/static/img/landing/typo-form-screen.png -------------------------------------------------------------------------------- /src/main/resources/static/img/landing/typo-info-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hexlet/hexlet-correction/HEAD/src/main/resources/static/img/landing/typo-info-screen.png -------------------------------------------------------------------------------- /src/main/resources/static/widget/typo-modal.js: -------------------------------------------------------------------------------- 1 | console.log('Script loaded'); 2 | // Test "" 3 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/test/Constraints.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.test; 2 | 3 | public class Constraints { 4 | 5 | public static final String POSTGRES_IMAGE = "postgres:14.2-alpine3.15"; 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/Identifiable.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain; 2 | 3 | public interface Identifiable { 4 | 5 | I getId(); 6 | 7 | > T setId(I id); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/QueryAccount.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service; 2 | 3 | public interface QueryAccount { 4 | 5 | boolean existsByUsername(String username); 6 | 7 | boolean existsByEmail(String email); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/workspace/AllowedUrlDTO.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.workspace; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.constraint.WorkspaceUrl; 4 | 5 | public record AllowedUrlDTO(@WorkspaceUrl String url) { 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/account/InfoAccount.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.account; 2 | 3 | public record InfoAccount( 4 | Long id, 5 | String email, 6 | String username, 7 | String firstName, 8 | String lastName 9 | ) { } 10 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/AccountRole.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace; 2 | 3 | public enum AccountRole { 4 | ROLE_ANONYMOUS, 5 | ROLE_WORKSPACE_API, 6 | ROLE_GUEST, 7 | ROLE_USER, 8 | ROLE_CORRECTOR, 9 | ROLE_ADMIN 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/ConvertibleToFieldError.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.validation.FieldError; 4 | 5 | public interface ConvertibleToFieldError { 6 | 7 | FieldError toFieldError(String objectName); 8 | } 9 | -------------------------------------------------------------------------------- /src/test/resources/datasets/workspaceRoles.yml: -------------------------------------------------------------------------------- 1 | workspace_role: 2 | - account_id: 101 3 | workspace_id: 101 4 | role: ROLE_CORRECTOR 5 | - account_id: 102 6 | workspace_id: 102 7 | role: ROLE_ANONYMOUS 8 | - account_id: 103 9 | workspace_id: 103 10 | role: ROLE_ADMIN 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 gradle:8.7-jdk21 2 | 3 | WORKDIR . 4 | 5 | # 6 | # Build stage 7 | # 8 | 9 | COPY . . 10 | 11 | RUN gradle build -x test 12 | 13 | # 14 | # Run application 15 | # 16 | 17 | CMD java -Xmx256m -jar build/libs/typoreporter-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod 18 | -------------------------------------------------------------------------------- /src/main/resources/templates/error-general.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/typo/UpdateTypoEvent.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.typo; 2 | 3 | import io.hexlet.typoreporter.domain.typo.TypoEvent; 4 | import jakarta.validation.constraints.NotNull; 5 | 6 | public record UpdateTypoEvent(@NotNull Long id, @NotNull TypoEvent typoEvent) { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/resources/templates/widget/report-typo-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/resources/templates/widget/report-typo-success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.oauth2; 2 | 3 | import java.util.Map; 4 | 5 | public interface OAuth2UserInfo { 6 | String getEmail(); 7 | String getUsername(); 8 | String getFirstName(); 9 | String getLastName(); 10 | Map getAttributes(); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/web/model/AccountModelMapper.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.web.model; 2 | 3 | import io.hexlet.typoreporter.service.account.signup.SignupAccount; 4 | import org.mapstruct.Mapper; 5 | 6 | @Mapper 7 | public interface AccountModelMapper { 8 | 9 | SignupAccount toSignupAccount(SignupAccountModel signupAccountModel); 10 | } 11 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | typo-reporter-db-postgresql: 4 | image: postgres:14.2-alpine3.15 5 | environment: 6 | - POSTGRES_DB=typo-reporter 7 | - POSTGRES_USER=developer 8 | - POSTGRES_HOST_AUTH_METHOD=trust 9 | ports: 10 | - "5432:5432" 11 | networks: 12 | typo-network: 13 | 14 | networks: 15 | typo-network: 16 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccountMapper.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.account.signup; 2 | 3 | import io.hexlet.typoreporter.web.model.SignupAccountModel; 4 | import org.mapstruct.Mapper; 5 | 6 | @Mapper 7 | public interface SignupAccountMapper { 8 | 9 | SignupAccount toSignupAccount(SignupAccountModel signupAccountModel); 10 | } 11 | -------------------------------------------------------------------------------- /src/test/resources/datasets/allowedUrls.yml: -------------------------------------------------------------------------------- 1 | allowed_url: 2 | - id: 101 3 | workspace_id: 101 4 | url: https://mysite.com 5 | - id: 102 6 | workspace_id: 102 7 | url: https://mysite.net 8 | - id: 103 9 | workspace_id: 103 10 | url: https://mysite.in 11 | - id: 104 12 | workspace_id: 101 13 | url: https://mysite2.com 14 | - id: 105 15 | workspace_id: 101 16 | url: https://mysite3.com 17 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/typo/ReportedTypo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.typo; 2 | 3 | import java.time.Instant; 4 | 5 | public record ReportedTypo( 6 | Long id, 7 | String pageUrl, 8 | String reporterName, 9 | String reporterComment, 10 | String textBeforeTypo, 11 | String textTypo, 12 | String textAfterTypo, 13 | String createdBy, 14 | Instant createdDate 15 | ) { 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/workspace/WorkspaceRoleInfo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.workspace; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.AccountRole; 4 | 5 | public record WorkspaceRoleInfo( 6 | Long workspaceId, 7 | Long accountId, 8 | String workspaceName, 9 | String workspaceUrl, 10 | String workspaceDescription, 11 | String username, 12 | AccountRole role 13 | ) { 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/workspace/WorkspaceInfo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.workspace; 2 | 3 | 4 | import java.time.Instant; 5 | 6 | public record WorkspaceInfo( 7 | Long id, 8 | String name, 9 | String url, 10 | String description, 11 | String createdBy, 12 | Instant createdDate, 13 | String createdDateAgo, 14 | String modifiedBy, 15 | Instant modifiedDate, 16 | String modifiedDateAgo 17 | ) { 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/utils/ValidationEmail.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.utils; 2 | 3 | import java.util.regex.Matcher; 4 | import java.util.regex.Pattern; 5 | 6 | public class ValidationEmail { 7 | public static boolean isValidEmail(String email) { 8 | String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"; 9 | Pattern pattern = Pattern.compile(emailRegex); 10 | Matcher matcher = pattern.matcher(email); 11 | return matcher.matches(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccountUseCase.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.account.signup; 2 | 3 | import io.hexlet.typoreporter.service.account.EmailAlreadyExistException; 4 | import io.hexlet.typoreporter.service.account.UsernameAlreadyExistException; 5 | import io.hexlet.typoreporter.service.dto.account.InfoAccount; 6 | 7 | public interface SignupAccountUseCase { 8 | 9 | InfoAccount signup(SignupAccount signupAccount) throws UsernameAlreadyExistException, EmailAlreadyExistException; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/workspace/CreateWorkspace.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.workspace; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.constraint.WorkspaceDescription; 4 | import io.hexlet.typoreporter.domain.workspace.constraint.WorkspaceName; 5 | import io.hexlet.typoreporter.domain.workspace.constraint.WorkspaceUrl; 6 | 7 | public record CreateWorkspace( 8 | @WorkspaceName String name, 9 | @WorkspaceDescription String description, 10 | @WorkspaceUrl String url 11 | ) { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccount.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.account.signup; 2 | 3 | public record SignupAccount( 4 | String username, 5 | String email, 6 | String password, 7 | String firstName, 8 | String lastName, 9 | String authProvider 10 | ) { 11 | 12 | @Override 13 | public String toString() { 14 | return "SignupAccount(username=%s, email=%s, firstName=%s, lastName=%s)" 15 | .formatted(username, email, firstName, lastName); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/repository/WorkspaceSettingsRepository.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import io.hexlet.typoreporter.domain.workspacesettings.WorkspaceSettings; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.Optional; 8 | 9 | @Repository 10 | public interface WorkspaceSettingsRepository extends JpaRepository { 11 | Optional getWorkspaceSettingsByWorkspaceId(Long wksId); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/web/model/WorkspaceUserModel.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.web.model; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | @Getter 10 | @Setter 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | public class WorkspaceUserModel { 14 | 15 | @Email(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", 16 | message = "The email \"${validatedValue}\" is not valid") 17 | private String email; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 |
8 |
9 |
10 |
11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/repository/AccountRepository.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import io.hexlet.typoreporter.domain.account.Account; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.Optional; 8 | 9 | @Repository 10 | public interface AccountRepository extends JpaRepository { 11 | 12 | Optional findAccountByEmail(String email); 13 | 14 | boolean existsByUsername(String username); 15 | boolean existsByEmail(String email); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/account/EmailAlreadyExistException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.account; 2 | 3 | import lombok.Getter; 4 | import org.springframework.core.NestedRuntimeException; 5 | 6 | import static java.text.MessageFormat.format; 7 | 8 | @Getter 9 | public class EmailAlreadyExistException extends NestedRuntimeException { 10 | 11 | private final String email; 12 | 13 | public EmailAlreadyExistException(String email) { 14 | super(format("Account with email ''{0}'' already exists", email)); 15 | this.email = email; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/static/fragments/time-converter.js: -------------------------------------------------------------------------------- 1 | function adjustDateToUserTimeZone(utcDate) { 2 | var adjustedTime = new Date(utcDate); 3 | return`${adjustedTime.toLocaleTimeString()} ${adjustedTime.toLocaleDateString()}`; 4 | } 5 | 6 | function adjustAllDatesOnPageToUserTimeZone() { 7 | document.querySelectorAll('[data-original-date]').forEach(function (element) { 8 | var originalTime = element.getAttribute('data-original-date'); 9 | element.textContent = adjustDateToUserTimeZone(originalTime); 10 | }); 11 | } 12 | 13 | document.addEventListener('DOMContentLoaded', adjustAllDatesOnPageToUserTimeZone); 14 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/account/UsernameAlreadyExistException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.account; 2 | 3 | import lombok.Getter; 4 | import org.springframework.core.NestedRuntimeException; 5 | 6 | import static java.text.MessageFormat.format; 7 | 8 | @Getter 9 | public class UsernameAlreadyExistException extends NestedRuntimeException { 10 | 11 | private final String username; 12 | 13 | public UsernameAlreadyExistException(String username) { 14 | super(format("Account with username ''{0}'' already exists", username)); 15 | this.username = username; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2025/06/2025-06-31-add-yandex-to-auth-provider.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ALTER TYPE auth_provider ADD VALUE 'YANDEX'; 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # We recommend you to keep these unchanged 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | # Change these settings to your own preference 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [Vagrantfile] 20 | indent_size = 2 21 | 22 | [*.{ts,tsx,js,jsx,json,css,scss,yml,yaml}] 23 | indent_size = 2 24 | 25 | [Makefile] 26 | indent_style = tab 27 | 28 | [*.md] 29 | trim_trailing_whitespace = false 30 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/typo/TypoInfo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.typo; 2 | 3 | import io.hexlet.typoreporter.domain.typo.TypoStatus; 4 | 5 | import java.time.Instant; 6 | 7 | public record TypoInfo( 8 | Long id, 9 | String pageUrl, 10 | String reporterName, 11 | String reporterComment, 12 | String textBeforeTypo, 13 | String textTypo, 14 | String textAfterTypo, 15 | String typoStatusStr, 16 | TypoStatus typoStatus, 17 | String createdBy, 18 | String createdDateAgo, 19 | Instant createdDate, 20 | String modifiedBy, 21 | String modifiedDateAgo, 22 | Instant modifiedDate 23 | ) { 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/TypoEvent.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo; 2 | 3 | public enum TypoEvent { 4 | OPEN, 5 | RESOLVE, 6 | REOPEN, 7 | CANCEL; 8 | 9 | public String getStyle() { 10 | return switch (this) { 11 | case OPEN, REOPEN -> "danger"; 12 | case RESOLVE -> "success"; 13 | case CANCEL -> "secondary"; 14 | }; 15 | } 16 | 17 | public String getButtonName() { 18 | return switch (this) { 19 | case OPEN -> "start"; 20 | case RESOLVE -> "resolve"; 21 | case REOPEN -> "restart"; 22 | case CANCEL -> "cancel"; 23 | }; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/WebConfig.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration 8 | public class WebConfig implements WebMvcConfigurer { 9 | 10 | @Override 11 | public void addViewControllers(ViewControllerRegistry registry) { 12 | registry.addViewController("/login").setViewName("login"); 13 | registry.addViewController("/about").setViewName("about"); 14 | registry.addViewController("/").setViewName("index"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2022/11/2022-11-03-add-unique-constraint-to-name-workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/AllowedUrlNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static org.springframework.http.HttpStatus.NOT_FOUND; 7 | 8 | public class AllowedUrlNotFoundException extends ErrorResponseException { 9 | public AllowedUrlNotFoundException(final String url, final Long wksId) { 10 | super(NOT_FOUND, ProblemDetail.forStatusAndDetail(NOT_FOUND, "Allowed URL not found"), null, 11 | "Allowed URL with name='" + url + "' for workspace with id=" + wksId + " not found", 12 | new Object[]{url}); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/FieldMatchConsiderCaseValidator.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto; 2 | 3 | import java.util.Objects; 4 | 5 | public class FieldMatchConsiderCaseValidator extends AbstractFieldMatchValidator { 6 | @Override 7 | public void initialize(final FieldMatchConsiderCase constraintAnnotation) { 8 | firstFieldName = constraintAnnotation.first(); 9 | secondFieldName = constraintAnnotation.second(); 10 | message = constraintAnnotation.message(); 11 | } 12 | 13 | @Override 14 | protected boolean areFieldsValid(Object firstField, Object secondField) { 15 | return Objects.equals(firstField, secondField); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/github-ci.yml: -------------------------------------------------------------------------------- 1 | name: Typo Reporter CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - '*' 10 | jobs: 11 | build: 12 | if: "!contains(github.event.head_commit.message, '[CI SKIP]')" 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up JDK 21 18 | uses: actions/setup-java@v2 19 | with: 20 | distribution: zulu 21 | java-version: 21 22 | - name: Run unit tests 23 | run: make test-unit-only 24 | - name: Run integration tests 25 | run: make test-integration-only 26 | - name: Run linter check 27 | run: make lint 28 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/AllowedUrlAlreadyExistException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static org.springframework.http.HttpStatus.BAD_REQUEST; 7 | 8 | public class AllowedUrlAlreadyExistException extends ErrorResponseException { 9 | public AllowedUrlAlreadyExistException(final String url, final Long wksId) { 10 | super(BAD_REQUEST, ProblemDetail.forStatusAndDetail(BAD_REQUEST, "Allowed URL already exists"), null, 11 | "URL \"" + url + "\" has already been added to workspace with ID=" + wksId, 12 | new Object[]{url}); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/TypoNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static java.text.MessageFormat.format; 7 | import static org.springframework.http.HttpStatus.NOT_FOUND; 8 | 9 | public class TypoNotFoundException extends ErrorResponseException { 10 | 11 | private static final String MESSAGE = "Typo with id=''{0}'' not found"; 12 | 13 | public TypoNotFoundException(final Long id) { 14 | super(NOT_FOUND, ProblemDetail.forStatusAndDetail(NOT_FOUND, "Typo not found"), 15 | null, format(MESSAGE, id), new Object[]{id}); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/account/AuthProvider.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.account; 2 | 3 | public enum AuthProvider { 4 | 5 | EMAIL("EMAIL"), GITHUB("GITHUB"), GOOGLE("GOOGLE"), YANDEX("YANDEX"); 6 | 7 | private String name; 8 | 9 | AuthProvider(String name) { 10 | this.name = name; 11 | } 12 | 13 | public String getName() { 14 | return name; 15 | } 16 | 17 | public static AuthProvider fromName(String name) { 18 | for (AuthProvider provider : values()) { 19 | if (provider.name.equals(name)) { 20 | return provider; 21 | } 22 | } 23 | throw new IllegalArgumentException("Provider not found: " + name); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/resources/datasets/workspace_settings.yml: -------------------------------------------------------------------------------- 1 | workspace_settings: 2 | - id: 101 3 | api_access_token: abb4f3ab-1994-4264-bc6f-7cd1aa13dc0a 4 | created_by: system 5 | created_date: 2020-11-15 00:09:55.391179 6 | modified_by: system 7 | modified_date: 2020-11-15 00:09:55.391179 8 | - id: 102 9 | api_access_token: f884c5a6-d698-4f90-aa21-f182540039fc 10 | created_by: system 11 | created_date: 2020-11-15 00:09:55.391179 12 | modified_by: system 13 | modified_date: 2020-11-15 00:09:55.391179 14 | - id: 103 15 | api_access_token: 669016b9-6445-4bdc-b734-14fee2454b04 16 | created_by: system 17 | created_date: 2020-11-15 00:09:55.391179 18 | modified_by: system 19 | modified_date: 2020-11-15 00:09:55.391179 20 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/CustomAccessDeniedHandler.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpServletResponse; 5 | import org.springframework.security.access.AccessDeniedException; 6 | import org.springframework.security.web.access.AccessDeniedHandler; 7 | 8 | import java.io.IOException; 9 | 10 | public class CustomAccessDeniedHandler implements AccessDeniedHandler { 11 | @Override 12 | public void handle(HttpServletRequest request, 13 | HttpServletResponse response, 14 | AccessDeniedException accessDeniedException) throws IOException { 15 | response.sendRedirect("/workspaces"); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task.yml: -------------------------------------------------------------------------------- 1 | name: "Task" 2 | description: Create a new ticket for a task. 3 | labels: 4 | - task 5 | 6 | body: 7 | - type: textarea 8 | id: problem-description 9 | attributes: 10 | label: "Problem description" 11 | description: Please enter an explicit description of the problem 12 | placeholder: Short and explicit description of the problem... 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | id: proposed-solution 18 | attributes: 19 | label: "Proposed solution" 20 | description: Please enter an explicit description of the proposed solution 21 | placeholder: Short and explicit description of the proposed solution... 22 | validations: 23 | required: true 24 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/mapper/AccountMapper.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.account.Account; 4 | import io.hexlet.typoreporter.service.account.signup.SignupAccount; 5 | import io.hexlet.typoreporter.service.dto.account.InfoAccount; 6 | import io.hexlet.typoreporter.service.dto.account.UpdateProfile; 7 | import org.mapstruct.Mapper; 8 | import org.mapstruct.MappingTarget; 9 | 10 | @Mapper 11 | public interface AccountMapper { 12 | 13 | InfoAccount toInfoAccount(Account source); 14 | 15 | UpdateProfile toUpdateProfile(Account source); 16 | 17 | Account toAccount(UpdateProfile source, @MappingTarget Account account); 18 | 19 | Account toAccount(SignupAccount source); 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/FieldMatchIgnoreCaseValidator.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto; 2 | 3 | import java.util.Objects; 4 | 5 | public class FieldMatchIgnoreCaseValidator extends AbstractFieldMatchValidator { 6 | @Override 7 | public void initialize(final FieldMatchIgnoreCase constraintAnnotation) { 8 | firstFieldName = constraintAnnotation.first(); 9 | secondFieldName = constraintAnnotation.second(); 10 | message = constraintAnnotation.message(); 11 | } 12 | 13 | @Override 14 | protected boolean areFieldsValid(Object firstField, Object secondField) { 15 | return Objects.equals(firstField.toString().toLowerCase(), secondField.toString().toLowerCase()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2023/02/2023-20-02-remove-many-to-one-account-workspace-relation.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/constraint/ReporterComment.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.Size; 6 | 7 | import java.lang.annotation.ElementType; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @Size(max = 200) 13 | @Constraint(validatedBy = {}) 14 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 15 | @Retention(RetentionPolicy.RUNTIME) 16 | public @interface ReporterComment { 17 | 18 | String message() default ""; 19 | 20 | Class[] groups() default {}; 21 | 22 | Class[] payload() default {}; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/repository/AllowedUrlRepository.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.AllowedUrl; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import java.util.List; 10 | import java.util.Optional; 11 | 12 | @Repository 13 | public interface AllowedUrlRepository extends JpaRepository { 14 | Optional findAllowedUrlByUrlAndWorkspaceId(String url, Long wksId); 15 | List findByWorkspaceId(Long wksId); 16 | Page findPageAllowedUrlByWorkspaceId(Pageable pageable, Long wksId); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/templates/about.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |

11 |

12 |

13 |

[[#{text.about-community}]] telegram 14 |

15 |
16 |
17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/AccountNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static java.text.MessageFormat.format; 7 | import static org.springframework.http.HttpStatus.NOT_FOUND; 8 | 9 | public class AccountNotFoundException extends ErrorResponseException { 10 | private static final String NOT_FOUND_MSG = "Account with email=''{0}'' not found"; 11 | 12 | public AccountNotFoundException(final String emailOrUserName) { 13 | super(NOT_FOUND, ProblemDetail.forStatusAndDetail(NOT_FOUND, "Account not found"), null, 14 | format(NOT_FOUND_MSG, emailOrUserName), new Object[]{emailOrUserName}); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/account/UpdatePassword.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.account; 2 | 3 | import io.hexlet.typoreporter.domain.account.constraint.AccountPassword; 4 | import io.hexlet.typoreporter.service.dto.FieldMatchConsiderCase; 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | import lombok.experimental.Accessors; 8 | 9 | @Getter 10 | @Setter 11 | @Accessors(chain = true) 12 | @FieldMatchConsiderCase( 13 | first = "newPassword", 14 | second = "confirmNewPassword", 15 | message = "The password and it confirmation must match") 16 | public class UpdatePassword { 17 | 18 | private String oldPassword; 19 | 20 | @AccountPassword 21 | private String newPassword; 22 | 23 | @AccountPassword 24 | private String confirmNewPassword; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/audit/AuditConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config.audit; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.data.domain.AuditorAware; 6 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 7 | 8 | import java.security.Principal; 9 | import java.util.Optional; 10 | 11 | import static org.springframework.security.core.context.SecurityContextHolder.getContext; 12 | 13 | @Configuration 14 | @EnableJpaAuditing 15 | public class AuditConfiguration { 16 | 17 | @Bean 18 | public AuditorAware auditorAware() { 19 | return () -> Optional.ofNullable(getContext().getAuthentication()).map(Principal::getName); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/thymeleaf/ThymeleafAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config.thymeleaf; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.thymeleaf.dialect.springdata.SpringDataDialect; 6 | import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; 7 | 8 | @Configuration 9 | public class ThymeleafAutoConfiguration { 10 | 11 | @Bean 12 | public SpringDataDialect springDataDialect() { 13 | return new SpringDataDialect(); 14 | } 15 | 16 | // Make available Thymeleaf Spring Security Dialect on the templates 17 | @Bean 18 | public SpringSecurityDialect springSecurityDialect() { 19 | return new SpringSecurityDialect(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/constraint/WorkspaceDescription.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.Size; 6 | 7 | import java.lang.annotation.ElementType; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @Size(max = 1000) 13 | @Constraint(validatedBy = {}) 14 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 15 | @Retention(RetentionPolicy.RUNTIME) 16 | public @interface WorkspaceDescription { 17 | 18 | String message() default ""; 19 | 20 | Class[] groups() default {}; 21 | 22 | Class[] payload() default {}; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoFactory.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.oauth2; 2 | 3 | import io.hexlet.typoreporter.domain.account.AuthProvider; 4 | 5 | import java.util.Map; 6 | 7 | public class OAuth2UserInfoFactory { 8 | 9 | public static OAuth2UserInfo getOAuth2UserInfo(String provider, String accessToken, 10 | Map attributes) { 11 | return switch (AuthProvider.fromName(provider.toUpperCase())) { 12 | case GITHUB -> new GithubOAuth2UserInfo(accessToken, attributes); 13 | case YANDEX -> new YandexOAuth2UserInfo(accessToken, attributes); 14 | default -> throw new IllegalArgumentException("Unsupported provider: " + provider); 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/CookieLocaleResolver.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config; 2 | 3 | import jakarta.servlet.http.Cookie; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.springframework.web.servlet.i18n.SessionLocaleResolver; 7 | 8 | import java.util.Locale; 9 | 10 | public class CookieLocaleResolver extends SessionLocaleResolver { 11 | private static final String COOKIE_NAME = "lang"; 12 | 13 | @Override 14 | public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) { 15 | super.setLocale(request, response, locale); 16 | if (response != null) { 17 | response.addCookie(new Cookie(COOKIE_NAME, locale.getLanguage())); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/oauth2/CustomOAuth2User.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.oauth2; 2 | 3 | import lombok.Getter; 4 | import org.springframework.security.core.GrantedAuthority; 5 | import org.springframework.security.oauth2.core.user.DefaultOAuth2User; 6 | 7 | import java.util.Collection; 8 | import java.util.Map; 9 | 10 | @Getter 11 | public class CustomOAuth2User extends DefaultOAuth2User { 12 | private final String nickname; 13 | 14 | public CustomOAuth2User(Collection authorities, 15 | Map attributes, 16 | String keyAttribute, 17 | String nickname) { 18 | super(authorities, attributes, keyAttribute); 19 | this.nickname = nickname; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2021/04/20212304094515440-add-column-api-access-token-to-typo.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/test/resources/datasets/workspaces.yml: -------------------------------------------------------------------------------- 1 | workspace: 2 | - id: 101 3 | name: Five words name with spaces 4 | url: https://mysite.com 5 | description: desc 6 | created_by: system 7 | created_date: 2020-11-15 00:09:55.391179 8 | modified_by: system 9 | modified_date: 2020-11-15 00:09:55.391179 10 | - id: 102 11 | name: wks-test 12 | url: https://mysite.net 13 | description: description 14 | created_by: system 15 | created_date: 2020-11-15 00:09:55.391179 16 | modified_by: system 17 | modified_date: 2020-11-15 00:09:55.391179 18 | - id: 103 19 | name: wks-empty 20 | url: https://mysite.in 21 | description: Empty workspace 22 | created_by: system 23 | created_date: 2020-11-15 00:09:55.391179 24 | modified_by: system 25 | modified_date: 2020-11-15 00:09:55.391179 26 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/constraint/TextTypo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.NotNull; 6 | import jakarta.validation.constraints.Size; 7 | 8 | import java.lang.annotation.ElementType; 9 | import java.lang.annotation.Retention; 10 | import java.lang.annotation.RetentionPolicy; 11 | import java.lang.annotation.Target; 12 | 13 | @NotNull 14 | @Size(max = 1000) 15 | @Constraint(validatedBy = {}) 16 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 17 | @Retention(RetentionPolicy.RUNTIME) 18 | public @interface TextTypo { 19 | 20 | String message() default ""; 21 | 22 | Class[] groups() default {}; 23 | 24 | Class[] payload() default {}; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2022/11/2022-11-04-add-column-url-to-workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/repository/WorkspaceRepository.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.Workspace; 4 | import org.springframework.data.jpa.repository.EntityGraph; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.Optional; 9 | 10 | @Repository 11 | public interface WorkspaceRepository extends JpaRepository { 12 | @EntityGraph(attributePaths = {"workspaceRoles.account"}) 13 | Optional getWorkspaceById(Long wksId); 14 | 15 | boolean existsWorkspaceByName(String wksName); 16 | 17 | boolean existsWorkspaceById(Long wksId); 18 | 19 | boolean existsWorkspaceByUrl(String url); 20 | 21 | Integer deleteWorkspaceById(Long wksId); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/account/UpdateProfile.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.account; 2 | 3 | import io.hexlet.typoreporter.domain.account.constraint.AccountUsername; 4 | import jakarta.validation.constraints.Email; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | import lombok.experimental.Accessors; 9 | 10 | @Getter 11 | @Setter 12 | @Accessors(chain = true) 13 | public class UpdateProfile { 14 | 15 | @AccountUsername 16 | private String username; 17 | 18 | @Email(regexp = "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", 19 | message = "The email \"${validatedValue}\" is not valid") 20 | private String email; 21 | 22 | @Size(max = 50) 23 | private String firstName; 24 | 25 | @Size(max = 50) 26 | private String lastName; 27 | } 28 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/service/mapper/TypoToTypoInfoMapperTest.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.MethodSource; 6 | import org.mapstruct.factory.Mappers; 7 | 8 | import static io.hexlet.typoreporter.test.asserts.TypoInfoAssert.assertThat; 9 | 10 | class TypoToTypoInfoMapperTest { 11 | 12 | private final TypoMapper typoMapper = Mappers.getMapper(TypoMapper.class); 13 | 14 | @ParameterizedTest 15 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getTypos") 16 | void requestToTypoInfo(final Typo typo) { 17 | final var typoInfo = typoMapper.toTypoInfo(typo); 18 | assertThat(typoInfo).isEqualsToTypoInfo(typo); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/featureRequest.yml: -------------------------------------------------------------------------------- 1 | name: "Feature request" 2 | description: Create a new ticket for a feature request. 3 | title: "Feature request: " 4 | labels: 5 | - feature request 6 | 7 | body: 8 | - type: textarea 9 | id: problem-description 10 | attributes: 11 | label: "Problem description" 12 | description: Please enter an explicit description of the proposed feature 13 | placeholder: Short and explicit description of the proposed feature... 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: proposed-solution 19 | attributes: 20 | label: "Proposed solution" 21 | description: Please enter an explicit description of your proposed solution 22 | placeholder: Short and explicit description of your proposed solution... 23 | validations: 24 | required: true 25 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/InvalidTypoEventException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static java.text.MessageFormat.format; 7 | import static org.springframework.http.HttpStatus.BAD_REQUEST; 8 | 9 | public class InvalidTypoEventException extends ErrorResponseException { 10 | 11 | private static final String MESSAGE = "Invalid event ''{0}'' for typo status ''{1}''. Valid events: {2}"; 12 | 13 | public InvalidTypoEventException(final TypoStatus status, final TypoEvent event) { 14 | super(BAD_REQUEST, ProblemDetail.forStatusAndDetail(BAD_REQUEST, "Invalid event"), null, 15 | format(MESSAGE, event, status, status.getValidEvents()), new Object[]{status, event}); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/service/mapper/TypoReportToTypoMapperTest.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.service.dto.typo.TypoReport; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.MethodSource; 6 | import org.mapstruct.factory.Mappers; 7 | 8 | import static io.hexlet.typoreporter.test.asserts.TypoAssert.assertThat; 9 | 10 | class TypoReportToTypoMapperTest { 11 | private final TypoMapper typoMapper = Mappers.getMapper(TypoMapper.class); 12 | 13 | @ParameterizedTest 14 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getTypoReport") 15 | void requestReportToTypo(final TypoReport report) { 16 | final var typo = typoMapper.toTypo(report); 17 | assertThat(typo).isEqualsToTypoReport(report); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/service/mapper/TypoToReportedTypoMapperTest.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.MethodSource; 6 | import org.mapstruct.factory.Mappers; 7 | 8 | import static io.hexlet.typoreporter.test.asserts.ReportedTypoAssert.assertThat; 9 | 10 | class TypoToReportedTypoMapperTest { 11 | private final TypoMapper typoMapper = Mappers.getMapper(TypoMapper.class); 12 | 13 | @ParameterizedTest 14 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getTypos") 15 | void typoToResponseReport(final Typo typo) { 16 | final var reportedTypo = typoMapper.toReportedTypo(typo); 17 | assertThat(reportedTypo).isEqualsToTypo(typo); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/utils/TextUtils.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.utils; 2 | 3 | import lombok.experimental.UtilityClass; 4 | 5 | import java.net.MalformedURLException; 6 | import java.net.URL; 7 | import java.util.Locale; 8 | 9 | @UtilityClass 10 | public final class TextUtils { 11 | 12 | public static String toLowerCaseData(final String inputData) { 13 | return inputData.toLowerCase(Locale.ROOT); 14 | } 15 | public static String trimUrl(final String inputUrl) { 16 | URL url = null; 17 | try { 18 | url = new URL(inputUrl); 19 | } catch (MalformedURLException e) { 20 | //TODO: write custom exception 21 | throw new RuntimeException(e); 22 | } 23 | return url.getProtocol() + "://" + url.getHost() + (url.getPort() != -1 ? ":" + url.getPort() : ""); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2022/12/2022-12-01-add-workspace_role-table.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2024/05/2024-05-31-remove-unique-constraint-to-name-workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/mapper/WorkspaceRoleMapper.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.WorkspaceRole; 4 | import io.hexlet.typoreporter.service.dto.workspace.WorkspaceRoleInfo; 5 | import org.mapstruct.Mapper; 6 | import org.mapstruct.Mapping; 7 | 8 | @Mapper 9 | public interface WorkspaceRoleMapper { 10 | 11 | @Mapping(target = "workspaceId", source = "workspace.id") 12 | @Mapping(target = "accountId", source = "account.id") 13 | @Mapping(target = "workspaceUrl", source = "workspace.url") 14 | @Mapping(target = "workspaceDescription", source = "workspace.description") 15 | @Mapping(target = "workspaceName", source = "workspace.name") 16 | @Mapping(target = "username", source = "account.username") 17 | WorkspaceRoleInfo toWorkspaceRoleInfo(WorkspaceRole source); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/templates/fragments/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | FixIT 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/test/DBUnitEnumPostgres.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.test; 2 | 3 | import org.dbunit.dataset.datatype.DataType; 4 | import org.dbunit.dataset.datatype.DataTypeException; 5 | import org.dbunit.ext.postgresql.PostgresqlDataTypeFactory; 6 | import org.springframework.context.annotation.Configuration; 7 | 8 | import static java.sql.Types.OTHER; 9 | 10 | @Configuration 11 | public class DBUnitEnumPostgres extends PostgresqlDataTypeFactory { 12 | 13 | @Override 14 | public DataType createDataType(int sqlType, String sqlTypeName) throws DataTypeException { 15 | return super.createDataType(isEnumType(sqlTypeName) ? OTHER : sqlType, sqlTypeName); 16 | } 17 | 18 | @Override 19 | public boolean isEnumType(String sqlTypeName) { 20 | return "typo_status".equalsIgnoreCase(sqlTypeName) || "auth_provider".equalsIgnoreCase(sqlTypeName); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/constraint/ReporterName.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.NotNull; 7 | import jakarta.validation.constraints.Size; 8 | 9 | import java.lang.annotation.ElementType; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.RetentionPolicy; 12 | import java.lang.annotation.Target; 13 | 14 | @NotNull 15 | @NotBlank 16 | @Size(max = 50) 17 | @Constraint(validatedBy = {}) 18 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 19 | @Retention(RetentionPolicy.RUNTIME) 20 | public @interface ReporterName { 21 | 22 | String message() default ""; 23 | 24 | Class[] groups() default {}; 25 | 26 | Class[] payload() default {}; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/ForbiddenDomainException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static java.text.MessageFormat.format; 7 | import static org.springframework.http.HttpStatus.FORBIDDEN; 8 | 9 | public class ForbiddenDomainException extends ErrorResponseException { 10 | 11 | private static final String MESSAGE = "Domain ''{0}'' is forbidden"; 12 | 13 | public ForbiddenDomainException(final String domain) { 14 | super(FORBIDDEN, ProblemDetail.forStatusAndDetail(FORBIDDEN, 15 | "Domain is forbidden"), 16 | null, 17 | format(MESSAGE, domain), 18 | new Object[]{domain}); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/WorkspaceRoleNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static java.text.MessageFormat.format; 7 | import static org.springframework.http.HttpStatus.NOT_FOUND; 8 | 9 | public class WorkspaceRoleNotFoundException extends ErrorResponseException { 10 | private static final String NAME_NOT_FOUND_MSG = "Workspace role with accountId=''{0}'' " 11 | + "and workspaceId=''{1}'' not found"; 12 | 13 | public WorkspaceRoleNotFoundException(final Long accountId, final Long workspaceId) { 14 | super(NOT_FOUND, ProblemDetail.forStatusAndDetail(NOT_FOUND, "Workspace role not found"), null, 15 | format(NAME_NOT_FOUND_MSG, accountId, workspaceId), new Object[]{accountId, workspaceId}); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/config/application-dev.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost:5432/typo-reporter 4 | username: developer 5 | driver-class-name: org.postgresql.Driver 6 | jpa: 7 | show-sql: true 8 | hibernate: 9 | ddl-auto: validate 10 | properties: 11 | hibernate: 12 | generate_statistics: true 13 | use_sql_comments: true 14 | format_sql: true 15 | thymeleaf: 16 | cache: false 17 | liquibase: 18 | contexts: dev 19 | mvc: 20 | hiddenmethod: 21 | filter: 22 | enabled: true 23 | logging: 24 | level: 25 | root: INFO 26 | web: DEBUG 27 | org.hibernate.type.descriptor.sql: TRACE 28 | org.springframework.security: TRACE 29 | org.springframework.web: DEBUG 30 | org.springframework.session: DEBUG 31 | io.hexlet.typoreporter: DEBUG 32 | server: 33 | error: 34 | include-stacktrace: on_param 35 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/test/asserts/TypoAssert.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.test.asserts; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import io.hexlet.typoreporter.service.dto.typo.TypoReport; 5 | import org.assertj.core.api.ObjectAssert; 6 | 7 | public class TypoAssert extends ObjectAssert { 8 | 9 | TypoAssert(Typo actual) { 10 | super(actual); 11 | } 12 | 13 | public static TypoAssert assertThat(Typo actual) { 14 | return new TypoAssert(actual); 15 | } 16 | 17 | public TypoAssert isEqualsToTypoReport(TypoReport expected) { 18 | isNotNull(); 19 | assertThat(actual) 20 | .usingRecursiveComparison() 21 | .ignoringFields("id", "typoStatus", "createdDate", "createdBy", 22 | "modifiedDate", "modifiedBy", "workspace", "account") 23 | .isEqualTo(expected); 24 | return this; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/account/constraint/AccountPassword.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.account.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.Pattern; 7 | 8 | import java.lang.annotation.ElementType; 9 | import java.lang.annotation.Retention; 10 | import java.lang.annotation.RetentionPolicy; 11 | import java.lang.annotation.Target; 12 | 13 | 14 | @NotBlank 15 | @Pattern(regexp = "\\p{Graph}{8,20}", message = "{alert.password-wrong-format}") 16 | @Constraint(validatedBy = {}) 17 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 18 | @Retention(RetentionPolicy.RUNTIME) 19 | public @interface AccountPassword { 20 | 21 | String message() default ""; 22 | 23 | Class[] groups() default {}; 24 | 25 | Class[] payload() default {}; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/constraint/TextBeforeTypo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.Size; 6 | import org.hibernate.validator.constraints.CompositionType; 7 | import org.hibernate.validator.constraints.ConstraintComposition; 8 | 9 | import java.lang.annotation.ElementType; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.RetentionPolicy; 12 | import java.lang.annotation.Target; 13 | 14 | @Size(max = 100) 15 | @ConstraintComposition(CompositionType.OR) 16 | @Constraint(validatedBy = {}) 17 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 18 | @Retention(RetentionPolicy.RUNTIME) 19 | public @interface TextBeforeTypo { 20 | 21 | String message() default ""; 22 | 23 | Class[] groups() default {}; 24 | 25 | Class[] payload() default {}; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/constraint/TypoPageUrl.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.NotNull; 7 | import jakarta.validation.constraints.Size; 8 | import org.hibernate.validator.constraints.URL; 9 | 10 | import java.lang.annotation.ElementType; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.RetentionPolicy; 13 | import java.lang.annotation.Target; 14 | 15 | @URL 16 | @NotNull 17 | @NotBlank 18 | @Size(max = 1000) 19 | @Constraint(validatedBy = {}) 20 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 21 | @Retention(RetentionPolicy.RUNTIME) 22 | public @interface TypoPageUrl { 23 | 24 | String message() default ""; 25 | 26 | Class[] groups() default {}; 27 | 28 | Class[] payload() default {}; 29 | } 30 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/test/asserts/TypoInfoAssert.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.test.asserts; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import io.hexlet.typoreporter.service.dto.typo.TypoInfo; 5 | import org.assertj.core.api.ObjectAssert; 6 | 7 | public class TypoInfoAssert extends ObjectAssert { 8 | 9 | public TypoInfoAssert(TypoInfo typoInfo) { 10 | super(typoInfo); 11 | } 12 | 13 | public static TypoInfoAssert assertThat(TypoInfo actual) { 14 | return new TypoInfoAssert(actual); 15 | } 16 | 17 | public TypoInfoAssert isEqualsToTypoInfo(Typo expected) { 18 | isNotNull(); 19 | assertThat(actual) 20 | .usingRecursiveComparison() 21 | .ignoringFields("typoStatusStr", "createdBy", "createdDateAgo", 22 | "createdDate", "modifiedBy", "modifiedDateAgo", "modifiedDate", "account") 23 | .isEqualTo(expected); 24 | return this; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/typo/TypoReport.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.typo; 2 | 3 | import io.hexlet.typoreporter.domain.typo.constraint.ReporterComment; 4 | import io.hexlet.typoreporter.domain.typo.constraint.ReporterName; 5 | import io.hexlet.typoreporter.domain.typo.constraint.TextAfterTypo; 6 | import io.hexlet.typoreporter.domain.typo.constraint.TextBeforeTypo; 7 | import io.hexlet.typoreporter.domain.typo.constraint.TextTypo; 8 | import io.hexlet.typoreporter.domain.typo.constraint.TypoPageUrl; 9 | 10 | public record TypoReport( 11 | @TypoPageUrl 12 | String pageUrl, 13 | @ReporterName 14 | String reporterName, 15 | @ReporterComment 16 | String reporterComment, 17 | @TextBeforeTypo 18 | String textBeforeTypo, 19 | @TextTypo 20 | String textTypo, 21 | @TextAfterTypo 22 | String textAfterTypo 23 | ) { 24 | 25 | public static TypoReport empty() { 26 | return new TypoReport("", "", "", "", "", ""); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/account/constraint/AccountUsername.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.account.constraint; 2 | 3 | 4 | import jakarta.validation.Constraint; 5 | import jakarta.validation.Payload; 6 | import jakarta.validation.constraints.NotBlank; 7 | import jakarta.validation.constraints.Pattern; 8 | import jakarta.validation.constraints.Size; 9 | 10 | import java.lang.annotation.ElementType; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.RetentionPolicy; 13 | import java.lang.annotation.Target; 14 | 15 | @NotBlank 16 | @Pattern(regexp = "^[-_A-Za-z0-9]*$", message = "{alert.username-wrong-format}") 17 | @Size(min = 2, max = 20) 18 | @Constraint(validatedBy = {}) 19 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 20 | @Retention(RetentionPolicy.RUNTIME) 21 | public @interface AccountUsername { 22 | 23 | String message() default ""; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] payload() default {}; 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/constraint/TextAfterTypo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.Null; 6 | import jakarta.validation.constraints.Size; 7 | import org.hibernate.validator.constraints.CompositionType; 8 | import org.hibernate.validator.constraints.ConstraintComposition; 9 | 10 | import java.lang.annotation.ElementType; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.RetentionPolicy; 13 | import java.lang.annotation.Target; 14 | 15 | @Null 16 | @Size(max = 100) 17 | @ConstraintComposition(CompositionType.OR) 18 | @Constraint(validatedBy = {}) 19 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 20 | @Retention(RetentionPolicy.RUNTIME) 21 | public @interface TextAfterTypo { 22 | 23 | String message() default ""; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] payload() default {}; 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/WorkspaceRoleId.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace; 2 | 3 | import jakarta.persistence.Embeddable; 4 | import lombok.AllArgsConstructor; 5 | import lombok.NoArgsConstructor; 6 | import lombok.ToString; 7 | 8 | import java.io.Serializable; 9 | import java.util.Objects; 10 | 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | @Embeddable 14 | @ToString 15 | public class WorkspaceRoleId implements Serializable { 16 | 17 | private Long workspaceId; 18 | 19 | private Long accountId; 20 | 21 | @Override 22 | public boolean equals(Object obj) { 23 | 24 | if (obj == this) { 25 | return true; 26 | } 27 | 28 | if (obj instanceof WorkspaceRoleId other) { 29 | return workspaceId.equals(other.workspaceId) && accountId.equals(other.accountId); 30 | } 31 | 32 | return false; 33 | } 34 | 35 | @Override 36 | public int hashCode() { 37 | return Objects.hash(workspaceId, accountId); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/constraint/WorkspaceName.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.NotEmpty; 7 | import jakarta.validation.constraints.NotNull; 8 | import jakarta.validation.constraints.Size; 9 | 10 | import java.lang.annotation.ElementType; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.RetentionPolicy; 13 | import java.lang.annotation.Target; 14 | 15 | @NotNull 16 | @NotBlank 17 | @NotEmpty 18 | @Size(min = 2, max = 200) 19 | //@Pattern(regexp = "^[-_A-Za-z0-9]*$") 20 | @Constraint(validatedBy = {}) 21 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 22 | @Retention(RetentionPolicy.RUNTIME) 23 | public @interface WorkspaceName { 24 | 25 | String message() default ""; 26 | 27 | Class[] groups() default {}; 28 | 29 | Class[] payload() default {}; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/controller/IndexController.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.controller; 2 | 3 | import io.hexlet.typoreporter.service.WorkspaceService; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.ui.Model; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | 10 | import java.security.Principal; 11 | 12 | @Controller 13 | @RequestMapping 14 | @RequiredArgsConstructor 15 | public class IndexController { 16 | 17 | private final WorkspaceService workspaceService; 18 | 19 | @GetMapping("/workspaces") 20 | public String index(final Model model, Principal principal) { 21 | if (principal != null) { 22 | final var email = principal.getName(); 23 | final var wksInfoList = workspaceService.getAllWorkspacesInfoByEmail(email); 24 | model.addAttribute("wksInfoList", wksInfoList); 25 | } 26 | return "workspaces"; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/constraint/WorkspaceUrl.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace.constraint; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.NotEmpty; 7 | import jakarta.validation.constraints.NotNull; 8 | import jakarta.validation.constraints.Size; 9 | import org.hibernate.validator.constraints.URL; 10 | 11 | import java.lang.annotation.ElementType; 12 | import java.lang.annotation.Retention; 13 | import java.lang.annotation.RetentionPolicy; 14 | import java.lang.annotation.Target; 15 | 16 | @NotNull 17 | @NotBlank 18 | @NotEmpty 19 | @Size(min = 2, max = 255) 20 | @URL 21 | @Constraint(validatedBy = {}) 22 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 23 | @Retention(RetentionPolicy.RUNTIME) 24 | public @interface WorkspaceUrl { 25 | 26 | String message() default ""; 27 | 28 | Class[] groups() default {}; 29 | 30 | Class[] payload() default {}; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/OldPasswordWrongException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.validation.FieldError; 5 | import org.springframework.web.ErrorResponseException; 6 | 7 | import static org.springframework.http.HttpStatus.CONFLICT; 8 | 9 | public class OldPasswordWrongException extends ErrorResponseException implements ConvertibleToFieldError { 10 | 11 | private static final String MESSAGE_TEMPLATE = "Wrong old password"; 12 | 13 | public OldPasswordWrongException() { 14 | super(CONFLICT, ProblemDetail.forStatusAndDetail(CONFLICT, "Wrong old password"), null, MESSAGE_TEMPLATE, null); 15 | } 16 | 17 | @Override 18 | public FieldError toFieldError(final String objectName) { 19 | return new FieldError( 20 | objectName, 21 | "oldPassword", 22 | null, 23 | false, 24 | null, 25 | null, 26 | MESSAGE_TEMPLATE 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/static/fragments/lang-switcher.js: -------------------------------------------------------------------------------- 1 | function getCurrentLanguage() { 2 | return getCookie('lang') || 'en'; 3 | } 4 | 5 | // update dropdown-toggle value 6 | function updateLanguageDropdown() { 7 | var currentLanguage = getCurrentLanguage(); 8 | var languageDropdown = document.getElementById('languageDropdown'); 9 | var languageText = document.getElementById('languageText'); 10 | languageDropdown.textContent = currentLanguage.toUpperCase(); 11 | } 12 | 13 | // init dropdown-toggle value on page load 14 | document.addEventListener('DOMContentLoaded', function() { 15 | updateLanguageDropdown(); 16 | }); 17 | 18 | function switchLanguage(lang) { 19 | var url = window.location.href.split('?')[0]; // get current URL without params 20 | window.location.href = url + '?lang=' + lang; // add param ?lang= and redirect to the current page 21 | } 22 | 23 | function getCookie(name) { 24 | let cookie = {}; 25 | document.cookie.split(';').forEach(function(el) { 26 | let [k,v] = el.split('='); 27 | cookie[k.trim()] = v; 28 | }) 29 | return cookie[name]; 30 | } 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | build: 4 | ./gradlew clean check 5 | 6 | package: 7 | ./gradlew clean bootJar -x test 8 | 9 | clear: 10 | ./gradlew clean 11 | docker compose -f docker/docker-compose.yml down -v 12 | 13 | setup: build 14 | 15 | test: 16 | ./gradlew unitTest integrationTest 17 | 18 | test-unit-only: 19 | ./gradlew unitTest 20 | 21 | test-integration-only: 22 | ./gradlew integrationTest 23 | 24 | run-dev: 25 | ./gradlew bootRun --args='--spring.profiles.active=dev' 26 | 27 | run-dev-docker-db: docker-infra-start run-dev 28 | 29 | start: run-dev-docker-db 30 | 31 | docker-infra-start: 32 | docker compose -f docker/docker-compose.yml up -d -V --remove-orphans 33 | 34 | run-dev-debug: 35 | ./gradlew bootRun --args='--spring.profiles.active=dev' -Dorg.gradle.jvmargs="-Xdebug" 36 | 37 | update-versions: 38 | ./gradlew dependencyUpdates 39 | 40 | vagrant-build: 41 | vagrant up 42 | vagrant ssh -c "cd /vagrant && make build" 43 | 44 | vagrant-run: 45 | vagrant ssh -c "cd /vagrant && make run-dev-docker-db" 46 | 47 | lint: 48 | ./gradlew checkstyleMain checkstyleTest 49 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/service/mapper/WorkspaceToWorkspaceInfoTest.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.Workspace; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.MethodSource; 6 | import org.mapstruct.factory.Mappers; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | 10 | class WorkspaceToWorkspaceInfoTest { 11 | 12 | private final WorkspaceMapper workspaceMapper = Mappers.getMapper(WorkspaceMapper.class); 13 | 14 | @ParameterizedTest 15 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getWorkspaces") 16 | void requestWorkspaceToInfo(final Workspace workspace) { 17 | final var workspaceInfo = workspaceMapper.toWorkspaceInfo(workspace); 18 | assertThat(workspaceInfo).usingRecursiveComparison() 19 | .ignoringFields("createdBy", "createdDate", "createdDateAgo", "modifiedBy", 20 | "modifiedDate", "modifiedDateAgo") 21 | .isEqualTo(workspace); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/NewPasswordTheSameException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.validation.FieldError; 5 | import org.springframework.web.ErrorResponseException; 6 | 7 | import static org.springframework.http.HttpStatus.CONFLICT; 8 | 9 | public class NewPasswordTheSameException extends ErrorResponseException implements ConvertibleToFieldError { 10 | 11 | private static final String MESSAGE_TEMPLATE = "New password is the same as the old one"; 12 | 13 | public NewPasswordTheSameException() { 14 | super(CONFLICT, ProblemDetail.forStatusAndDetail(CONFLICT, "The same new password"), 15 | null, MESSAGE_TEMPLATE, null); 16 | } 17 | 18 | @Override 19 | public FieldError toFieldError(final String objectName) { 20 | return new FieldError( 21 | objectName, 22 | "newPassword", 23 | null, 24 | false, 25 | null, 26 | null, 27 | MESSAGE_TEMPLATE 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/repository/WorkspaceRoleRepository.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.WorkspaceRole; 4 | import org.springframework.data.jpa.repository.EntityGraph; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.List; 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public interface WorkspaceRoleRepository extends JpaRepository { 13 | 14 | @EntityGraph(attributePaths = {"account", "workspace"}) 15 | List getWorkspaceRolesByAccountId(Long accountId); 16 | 17 | @EntityGraph(attributePaths = {"account", "workspace"}) 18 | List getWorkspaceRolesByAccountEmail(String email); 19 | 20 | @EntityGraph(attributePaths = {"account", "workspace"}) 21 | List getWorkspaceRolesByWorkspaceId(Long workspaceId); 22 | 23 | @EntityGraph(attributePaths = {"account", "workspace"}) 24 | Optional getWorkspaceRoleByAccountIdAndWorkspaceId(Long accountId, Long workspaceId); 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/oauth2/YandexOAuth2UserInfo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.oauth2; 2 | 3 | import java.util.Map; 4 | 5 | public class YandexOAuth2UserInfo implements OAuth2UserInfo { 6 | 7 | private final String accessToken; 8 | private final Map attributes; 9 | 10 | public YandexOAuth2UserInfo(String accessToken, Map attributes) { 11 | this.accessToken = accessToken; 12 | this.attributes = attributes; 13 | } 14 | 15 | @Override 16 | public String getEmail() { 17 | return (String) attributes.get("default_email"); 18 | } 19 | 20 | @Override 21 | public String getUsername() { 22 | return (String) attributes.get("login"); 23 | } 24 | 25 | @Override 26 | public String getFirstName() { 27 | return (String) attributes.get("first_name"); 28 | } 29 | 30 | @Override 31 | public String getLastName() { 32 | return (String) attributes.get("last_name"); 33 | } 34 | 35 | @Override 36 | public Map getAttributes() { 37 | return attributes; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/service/mapper/CreateWorkspaceToWorkspaceTest.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.service.dto.workspace.CreateWorkspace; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | 6 | import org.junit.jupiter.params.provider.MethodSource; 7 | import org.mapstruct.factory.Mappers; 8 | 9 | import static org.assertj.core.api.Assertions.assertThat; 10 | 11 | class CreateWorkspaceToWorkspaceTest { 12 | 13 | private final WorkspaceMapper workspaceMapper = Mappers.getMapper(WorkspaceMapper.class); 14 | 15 | @ParameterizedTest 16 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getCreateWorkspaces") 17 | void requestReportToTypo(final CreateWorkspace createWorkspace) { 18 | final var workspace = workspaceMapper.toWorkspace(createWorkspace); 19 | assertThat(workspace).usingRecursiveComparison() 20 | .ignoringFields("id", "typos", "createdDate", "createdBy", "modifiedDate", "modifiedBy", 21 | "workspaceRoles", "accounts", "workspaceSettings", "allowedUrls") 22 | .isEqualTo(createWorkspace); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/resources/datasets/accounts.yml: -------------------------------------------------------------------------------- 1 | account: 2 | - id: 101 3 | created_by: system 4 | created_date: 2020-11-15 00:09:55.391179 5 | modified_by: system 6 | modified_date: 2020-11-15 00:09:55.391179 7 | external_open_id: qwer-qwer-12 8 | auth_provider: EMAIL 9 | email: test101@gmail.com 10 | username: test1 11 | password: testtest 12 | first_name: Test1 13 | last_name: Testing 14 | 15 | - id: 102 16 | created_by: system 17 | created_date: 2020-11-15 00:09:55.391179 18 | modified_by: system 19 | modified_date: 2020-11-15 00:09:55.391179 20 | external_open_id: qwer-qwer-12 21 | auth_provider: EMAIL 22 | email: test102@gmail.com 23 | username: test2 24 | password: testtest 25 | first_name: Test2 26 | last_name: Testing 27 | 28 | - id: 103 29 | created_by: system 30 | created_date: 2020-11-15 00:09:55.391179 31 | modified_by: system 32 | modified_date: 2020-11-15 00:09:55.391179 33 | external_open_id: qwer-qwer-12 34 | auth_provider: EMAIL 35 | email: test103@gmail.com 36 | username: test3 37 | password: testtest 38 | first_name: Test3 39 | last_name: Testing 40 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/WorkspaceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.web.ErrorResponseException; 5 | 6 | import static java.text.MessageFormat.format; 7 | import static org.springframework.http.HttpStatus.NOT_FOUND; 8 | 9 | public class WorkspaceNotFoundException extends ErrorResponseException { 10 | 11 | private static final String NAME_NOT_FOUND_MSG = "Workspace with name=''{0}'' not found"; 12 | 13 | private static final String ID_NOT_FOUND_MSG = "Workspace with id=''{0}'' not found"; 14 | 15 | public WorkspaceNotFoundException(final Long id) { 16 | super(NOT_FOUND, ProblemDetail.forStatusAndDetail(NOT_FOUND, "Workspace not found"), 17 | null, format(ID_NOT_FOUND_MSG, id), new Object[]{id}); 18 | } 19 | 20 | public WorkspaceNotFoundException(final String wksIdStr, final Throwable cause) { 21 | super(NOT_FOUND, ProblemDetail.forStatusAndDetail(NOT_FOUND, "Workspace not found"), cause, 22 | "Workspace with id='" + wksIdStr + "' not found", new Object[]{wksIdStr}); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/test/asserts/ReportedTypoAssert.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.test.asserts; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import io.hexlet.typoreporter.service.dto.typo.ReportedTypo; 5 | import io.hexlet.typoreporter.service.dto.typo.TypoReport; 6 | import org.assertj.core.api.ObjectAssert; 7 | 8 | public class ReportedTypoAssert extends ObjectAssert { 9 | 10 | ReportedTypoAssert(ReportedTypo actual) { 11 | super(actual); 12 | } 13 | 14 | public static ReportedTypoAssert assertThat(ReportedTypo actual) { 15 | return new ReportedTypoAssert(actual); 16 | } 17 | 18 | public ReportedTypoAssert isEqualsToTypo(Typo expected) { 19 | isNotNull(); 20 | assertThat(actual).usingRecursiveComparison().isEqualTo(expected); 21 | return this; 22 | } 23 | 24 | public ReportedTypoAssert isEqualsToTypoReport(TypoReport expected) { 25 | isNotNull(); 26 | assertThat(actual) 27 | .usingRecursiveComparison() 28 | .ignoringFields("id", "createdDate", "createdBy") 29 | .isEqualTo(expected); 30 | return this; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "ubuntu/focal64" 6 | config.vm.hostname = "hexlet-correction" 7 | config.vm.define "hexlet-correction" 8 | config.vm.network "forwarded_port", guest: 8080, host: 8080 9 | config.vm.provider "virtualbox" do |vb| 10 | vb.name = "hexlet-correction" 11 | vb.memory = "1024" 12 | end 13 | 14 | config.vm.provision "shell", inline: <<-SHELL 15 | apt update 16 | apt install -y make 17 | curl -fsSL https://get.docker.com -o get-docker.sh 18 | sh get-docker.sh 19 | 20 | groupadd docker 21 | usermod -aG docker vagrant 22 | docker run hello-world 23 | 24 | curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 25 | chmod +x /usr/local/bin/docker-compose 26 | 27 | apt update 28 | apt install -y software-properties-common 29 | 30 | # Java 31 | add-apt-repository ppa:openjdk-r/ppa 32 | apt install -y openjdk-17-jdk 33 | 34 | # Сборщик проектов 35 | add-apt-repository ppa:cwchien/gradle 36 | apt install -y gradle 37 | SHELL 38 | end 39 | -------------------------------------------------------------------------------- /src/main/resources/templates/fragments/workspace.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
13 |
14 |
15 |
16 |

17 | 18 | 19 | 20 |

21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/mapper/WorkspaceMapper.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.workspace.Workspace; 4 | import io.hexlet.typoreporter.service.dto.workspace.CreateWorkspace; 5 | import io.hexlet.typoreporter.service.dto.workspace.WorkspaceInfo; 6 | import org.mapstruct.Mapper; 7 | import org.mapstruct.Mapping; 8 | import org.mapstruct.Named; 9 | import org.ocpsoft.prettytime.PrettyTime; 10 | import org.springframework.context.i18n.LocaleContextHolder; 11 | 12 | import java.time.Instant; 13 | 14 | @Mapper 15 | public interface WorkspaceMapper { 16 | 17 | PrettyTime PRETTY_TIME = new PrettyTime(); 18 | 19 | Workspace toWorkspace(CreateWorkspace source); 20 | 21 | @Mapping(target = "createdDateAgo", source = "createdDate", qualifiedByName = "mapToPrettyDateAgo") 22 | @Mapping(target = "modifiedDateAgo", source = "modifiedDate", qualifiedByName = "mapToPrettyDateAgo") 23 | WorkspaceInfo toWorkspaceInfo(Workspace source); 24 | 25 | @Named(value = "mapToPrettyDateAgo") 26 | default String getDateAgoAsString(Instant date) { 27 | PRETTY_TIME.setLocale(LocaleContextHolder.getLocale()); 28 | return PRETTY_TIME.format(date); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/TypoReporterApplicationIT.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter; 2 | 3 | import io.hexlet.typoreporter.test.Constraints; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.DynamicPropertyRegistry; 7 | import org.springframework.test.context.DynamicPropertySource; 8 | import org.testcontainers.containers.PostgreSQLContainer; 9 | import org.testcontainers.junit.jupiter.Container; 10 | import org.testcontainers.junit.jupiter.Testcontainers; 11 | 12 | @Testcontainers 13 | @SpringBootTest 14 | class TypoReporterApplicationIT { 15 | 16 | @Container 17 | public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(Constraints.POSTGRES_IMAGE) 18 | .withPassword("inmemory") 19 | .withUsername("inmemory"); 20 | 21 | @DynamicPropertySource 22 | static void datasourceProperties(DynamicPropertyRegistry registry) { 23 | registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); 24 | registry.add("spring.datasource.password", postgreSQLContainer::getPassword); 25 | registry.add("spring.datasource.username", postgreSQLContainer::getUsername); 26 | } 27 | 28 | @Test 29 | void contextLoads() { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/repository/TypoRepository.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import io.hexlet.typoreporter.domain.typo.TypoStatus; 5 | import org.apache.commons.lang3.tuple.Pair; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | import org.springframework.data.jpa.repository.Query; 10 | import org.springframework.stereotype.Repository; 11 | 12 | import java.util.List; 13 | import java.util.Optional; 14 | 15 | @Repository 16 | public interface TypoRepository extends JpaRepository { 17 | Page findPageTypoByWorkspaceId(Pageable pageable, Long wksId); 18 | 19 | Page findPageTypoByWorkspaceIdAndTypoStatus(Pageable pageable, Long wksId, TypoStatus status); 20 | 21 | Integer deleteTypoById(Long id); 22 | 23 | @Query(""" 24 | select new org.apache.commons.lang3.tuple.ImmutablePair(t.typoStatus, count(t)) 25 | from Typo t 26 | where t.workspace.id = :wksId 27 | group by t.typoStatus 28 | """) 29 | List> getCountTypoStatusForWorkspaceId(Long wksId); 30 | 31 | Optional findFirstByWorkspaceIdOrderByCreatedDateDesc(Long wksId); 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/AbstractAuditingEntity.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.EntityListeners; 5 | import jakarta.persistence.MappedSuperclass; 6 | import lombok.Getter; 7 | import org.springframework.data.annotation.CreatedBy; 8 | import org.springframework.data.annotation.CreatedDate; 9 | import org.springframework.data.annotation.LastModifiedBy; 10 | import org.springframework.data.annotation.LastModifiedDate; 11 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 12 | 13 | import java.io.Serial; 14 | import java.io.Serializable; 15 | import java.time.Instant; 16 | 17 | @Getter 18 | @MappedSuperclass 19 | @EntityListeners(AuditingEntityListener.class) 20 | public abstract class AbstractAuditingEntity implements Serializable { 21 | 22 | @Serial 23 | private static final long serialVersionUID = 1L; 24 | 25 | @CreatedBy 26 | @Column(nullable = false, updatable = false) 27 | private String createdBy; 28 | 29 | @CreatedDate 30 | @Column(nullable = false, updatable = false) 31 | private Instant createdDate; 32 | 33 | @LastModifiedBy 34 | @Column(nullable = false) 35 | private String modifiedBy; 36 | 37 | @LastModifiedDate 38 | @Column(nullable = false) 39 | private Instant modifiedDate; 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/mapper/TypoMapper.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.mapper; 2 | 3 | import io.hexlet.typoreporter.domain.typo.Typo; 4 | import io.hexlet.typoreporter.service.dto.typo.ReportedTypo; 5 | import io.hexlet.typoreporter.service.dto.typo.TypoInfo; 6 | import io.hexlet.typoreporter.service.dto.typo.TypoReport; 7 | import org.mapstruct.Mapper; 8 | import org.mapstruct.Mapping; 9 | import org.mapstruct.Named; 10 | import org.ocpsoft.prettytime.PrettyTime; 11 | import org.springframework.context.i18n.LocaleContextHolder; 12 | 13 | import java.time.Instant; 14 | 15 | @Mapper 16 | public interface TypoMapper { 17 | 18 | PrettyTime PRETTY_TIME = new PrettyTime(); 19 | 20 | Typo toTypo(TypoReport source); 21 | 22 | @Mapping(target = "modifiedDateAgo", source = "modifiedDate", qualifiedByName = "mapToPrettyDateAgo") 23 | @Mapping(target = "createdDateAgo", source = "createdDate", qualifiedByName = "mapToPrettyDateAgo") 24 | @Mapping(target = "typoStatusStr", source = "typoStatus") 25 | TypoInfo toTypoInfo(Typo source); 26 | 27 | ReportedTypo toReportedTypo(Typo source); 28 | 29 | @Named(value = "mapToPrettyDateAgo") 30 | default String getDateAgoAsString(Instant date) { 31 | PRETTY_TIME.setLocale(LocaleContextHolder.getLocale()); 32 | return PRETTY_TIME.format(date); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2024/05/2024-05-01-add-workspace-urls-table.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/account/CustomUserDetails.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto.account; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import org.springframework.security.core.GrantedAuthority; 6 | import org.springframework.security.core.userdetails.UserDetails; 7 | import java.util.Collection; 8 | @AllArgsConstructor 9 | public class CustomUserDetails implements UserDetails { 10 | 11 | private String email; 12 | private String password; 13 | @Getter 14 | private final String nickname; 15 | private Collection authorities; 16 | 17 | @Override 18 | public String getUsername() { 19 | return email; 20 | } 21 | 22 | @Override 23 | public String getPassword() { 24 | return password; 25 | } 26 | 27 | @Override 28 | public Collection getAuthorities() { 29 | return authorities; 30 | } 31 | 32 | @Override 33 | public boolean isAccountNonExpired() { 34 | return true; 35 | } 36 | 37 | @Override 38 | public boolean isAccountNonLocked() { 39 | return true; 40 | } 41 | 42 | @Override 43 | public boolean isCredentialsNonExpired() { 44 | return true; 45 | } 46 | 47 | @Override 48 | public boolean isEnabled() { 49 | return true; 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Github Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v3 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload entire repository 40 | path: src/widget 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /src/main/resources/config/application-prod.yml: -------------------------------------------------------------------------------- 1 | # =================================================================== 2 | # Spring Boot configuration for the "prod" profile. 3 | # 4 | # This configuration overrides the application.yml file. 5 | # =================================================================== 6 | 7 | # =================================================================== 8 | # Standard Spring Boot properties. 9 | # Full reference is available at: 10 | # http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html 11 | # =================================================================== 12 | 13 | logging: 14 | level: 15 | ROOT: INFO 16 | 17 | spring: 18 | devtools: 19 | restart: 20 | enabled: false 21 | livereload: 22 | enabled: false 23 | datasource: 24 | url: ${JDBC_DATABASE_URL} 25 | username: ${JDBC_DATABASE_USERNAME} 26 | password: ${JDBC_DATABASE_PASSWORD} 27 | driver-class-name: org.postgresql.Driver 28 | jpa: 29 | show-sql: false 30 | hibernate: 31 | ddl-auto: none 32 | thymeleaf: 33 | cache: true 34 | liquibase: 35 | contexts: prod 36 | server: 37 | port: 8080 38 | compression: 39 | enabled: true 40 | mime-types: text/html,text/xml,text/plain,text/css, application/javascript, application/json, application/problem+json 41 | min-response-size: 1024 42 | error: 43 | include-stacktrace: never 44 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/AccountAlreadyExistException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.validation.FieldError; 5 | import org.springframework.web.ErrorResponseException; 6 | 7 | import static java.text.MessageFormat.format; 8 | import static org.springframework.http.HttpStatus.CONFLICT; 9 | 10 | public class AccountAlreadyExistException extends ErrorResponseException { 11 | 12 | private static final String MESSAGE_TEMPLATE = "Account with {0} ''{1}'' already exists"; 13 | 14 | private final String fieldName; 15 | 16 | private final String errorValue; 17 | 18 | public AccountAlreadyExistException(final String fieldName, final String errorValue) { 19 | super(CONFLICT, ProblemDetail.forStatusAndDetail(CONFLICT, "Account already exists"), null, 20 | format(MESSAGE_TEMPLATE, fieldName, errorValue), new Object[]{fieldName, errorValue}); 21 | this.fieldName = fieldName; 22 | this.errorValue = errorValue; 23 | } 24 | 25 | public FieldError toFieldError(final String objectName) { 26 | return new FieldError( 27 | objectName, 28 | fieldName, 29 | errorValue, 30 | false, 31 | null, 32 | null, 33 | format(MESSAGE_TEMPLATE, fieldName, errorValue) 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/exception/WorkspaceAlreadyExistException.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler.exception; 2 | 3 | import org.springframework.http.ProblemDetail; 4 | import org.springframework.validation.FieldError; 5 | import org.springframework.web.ErrorResponseException; 6 | 7 | import static java.text.MessageFormat.format; 8 | import static org.springframework.http.HttpStatus.BAD_REQUEST; 9 | 10 | public class WorkspaceAlreadyExistException extends ErrorResponseException { 11 | 12 | private static final String MESSAGE_TEMPLATE = "Workspace with {0} ''{1}'' already exists"; 13 | 14 | private final String fieldName; 15 | 16 | private final String errorValue; 17 | 18 | public WorkspaceAlreadyExistException(String fieldName, String errorValue) { 19 | super(BAD_REQUEST, ProblemDetail.forStatusAndDetail(BAD_REQUEST, "Workspace already exists"), null, 20 | format(MESSAGE_TEMPLATE, fieldName, errorValue), 21 | new Object[]{fieldName, errorValue}); 22 | this.fieldName = fieldName; 23 | this.errorValue = errorValue; 24 | } 25 | 26 | public FieldError toFieldError(String objectName) { 27 | return new FieldError( 28 | objectName, 29 | fieldName, 30 | errorValue, 31 | false, 32 | null, 33 | null, 34 | format(MESSAGE_TEMPLATE, fieldName, errorValue) 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/web/model/SignupAccountModel.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.web.model; 2 | 3 | import io.hexlet.typoreporter.domain.account.constraint.AccountPassword; 4 | import io.hexlet.typoreporter.domain.account.constraint.AccountUsername; 5 | import io.hexlet.typoreporter.service.dto.FieldMatchConsiderCase; 6 | import jakarta.validation.constraints.Email; 7 | import jakarta.validation.constraints.Size; 8 | import lombok.AllArgsConstructor; 9 | import lombok.NoArgsConstructor; 10 | import lombok.Getter; 11 | import lombok.Setter; 12 | import lombok.ToString; 13 | 14 | @Getter 15 | @Setter 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @FieldMatchConsiderCase( 19 | first = "password", 20 | second = "confirmPassword", 21 | message = "{alert.passwords-dont-match}") 22 | @ToString 23 | public class SignupAccountModel { 24 | 25 | @AccountUsername 26 | private String username; 27 | 28 | @Email(regexp = "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", 29 | message = "The email \"${validatedValue}\" is not valid") 30 | private String email; 31 | 32 | @AccountPassword 33 | @ToString.Exclude 34 | private String password; 35 | 36 | @AccountPassword 37 | @ToString.Exclude 38 | private String confirmPassword; 39 | 40 | @Size(max = 50) 41 | private String firstName; 42 | 43 | @Size(max = 50) 44 | private String lastName; 45 | 46 | 47 | private String authProvider; 48 | } 49 | -------------------------------------------------------------------------------- /vagrant_provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | IFS=$(printf '\n\t') 7 | 8 | # Docker 9 | sudo apt remove --yes docker docker-engine docker.io containerd runc || true 10 | sudo apt update 11 | sudo apt --yes --no-install-recommends install apt-transport-https ca-certificates 12 | wget --quiet --output-document=- https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - 13 | sudo add-apt-repository --yes "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/ubuntu $(lsb_release --codename --short) stable" 14 | sudo apt update 15 | sudo apt --yes --no-install-recommends install docker-ce docker-ce-cli containerd.io 16 | sudo usermod --append --groups docker "$USER" 17 | sudo systemctl enable docker 18 | printf '\nDocker installed successfully\n\n' 19 | 20 | printf 'Waiting for Docker to start...\n\n' 21 | sleep 5 22 | 23 | # Docker Compose 24 | sudo wget --output-document=/usr/local/bin/docker-compose "https://github.com/docker/compose/releases/download/$(wget --quiet --output-document=- https://api.github.com/repos/docker/compose/releases/latest | grep --perl-regexp --only-matching '"tag_name": "\K.*?(?=")')/run.sh" 25 | sudo chmod +x /usr/local/bin/docker-compose 26 | sudo wget --output-document=/etc/bash_completion.d/docker-compose "https://raw.githubusercontent.com/docker/compose/$(docker-compose version --short)/contrib/completion/bash/docker-compose" 27 | printf '\nDocker Compose installed successfully\n\n' 28 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/security/service/SecuredWorkspaceService.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.security.service; 2 | 3 | import io.hexlet.typoreporter.repository.WorkspaceSettingsRepository; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.security.core.userdetails.User; 6 | import org.springframework.security.core.userdetails.UserDetails; 7 | import org.springframework.security.core.userdetails.UserDetailsService; 8 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 9 | import org.springframework.stereotype.Service; 10 | 11 | @Service 12 | @RequiredArgsConstructor 13 | public class SecuredWorkspaceService implements UserDetailsService { 14 | 15 | private final WorkspaceSettingsRepository settingsRepository; 16 | 17 | @Override 18 | public UserDetails loadUserByUsername(String idStr) throws UsernameNotFoundException { 19 | try { 20 | return settingsRepository.findById(Long.parseLong(idStr)) 21 | .map(settings -> User.withUsername(idStr) 22 | .password(settings.getApiAccessToken().toString()) 23 | .authorities("ROLE_WORKSPACE_API") 24 | .build() 25 | ) 26 | .orElseThrow(() -> new UsernameNotFoundException("Workspace with id='" + idStr + "' not found")); 27 | } catch (NumberFormatException e) { 28 | throw new UsernameNotFoundException("Workspace id '" + idStr + "' is not a parsable long.", e); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/WorkspaceRole.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace; 2 | 3 | import io.hexlet.typoreporter.domain.account.Account; 4 | import jakarta.persistence.EmbeddedId; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.Enumerated; 7 | import jakarta.persistence.FetchType; 8 | import jakarta.persistence.ManyToOne; 9 | import jakarta.persistence.MapsId; 10 | import jakarta.validation.constraints.NotNull; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Getter; 13 | import lombok.NoArgsConstructor; 14 | import lombok.Setter; 15 | import lombok.ToString; 16 | 17 | import static jakarta.persistence.EnumType.STRING; 18 | 19 | @Getter 20 | @Setter 21 | @ToString 22 | @NoArgsConstructor 23 | @Entity 24 | @AllArgsConstructor 25 | public class WorkspaceRole { 26 | 27 | @EmbeddedId 28 | private WorkspaceRoleId id; 29 | 30 | @NotNull 31 | @Enumerated(STRING) 32 | private AccountRole role; 33 | 34 | @ManyToOne(fetch = FetchType.LAZY) 35 | @MapsId("workspaceId") 36 | @ToString.Exclude 37 | private Workspace workspace; 38 | 39 | @ManyToOne(fetch = FetchType.LAZY) 40 | @MapsId("accountId") 41 | @ToString.Exclude 42 | private Account account; 43 | 44 | @Override 45 | public boolean equals(Object o) { 46 | return this == o || id != null && o instanceof WorkspaceRole other && id.equals(other.id); 47 | } 48 | 49 | @Override 50 | public int hashCode() { 51 | return getClass().hashCode(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/FieldMatchConsiderCase.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Retention; 8 | import java.lang.annotation.RetentionPolicy; 9 | import java.lang.annotation.Target; 10 | 11 | /** 12 | * Validation annotation to validate that 2 fields have the same value. 13 | * An array of fields and their matching confirmation fields can be supplied. 14 | *
15 | * Example, compare 1 pair of fields: 16 | *
17 | * {@code @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match")} 18 | *
19 | * Example, compare more than 1 pair of fields: 20 | *
21 | * {@code @FieldMatchConsiderCase( 22 | * first = "password", 23 | * second = "confirmPassword", 24 | * message = "The password and it confirmation must match")} 25 | */ 26 | @Constraint(validatedBy = FieldMatchConsiderCaseValidator.class) 27 | @Target({ ElementType.TYPE }) 28 | @Retention(RetentionPolicy.RUNTIME) 29 | public @interface FieldMatchConsiderCase { 30 | 31 | String message() default "The {first} and {second} fields must be equal"; 32 | 33 | Class[] groups() default {}; 34 | 35 | Class[] payload() default {}; 36 | 37 | /** 38 | * @return The first field 39 | */ 40 | String first(); 41 | 42 | /** 43 | * @return The second field 44 | */ 45 | String second(); 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/I18nConfig.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config; 2 | 3 | import org.springframework.context.MessageSource; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.context.support.ResourceBundleMessageSource; 7 | import org.springframework.web.servlet.LocaleResolver; 8 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 10 | import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; 11 | 12 | import java.util.Locale; 13 | 14 | @Configuration 15 | public class I18nConfig implements WebMvcConfigurer { 16 | @Bean 17 | public LocaleResolver localeResolver() { 18 | var slr = new CookieLocaleResolver(); 19 | slr.setDefaultLocale(Locale.US); 20 | return slr; 21 | } 22 | 23 | @Bean 24 | public LocaleChangeInterceptor localeChangeInterceptor() { 25 | var lci = new LocaleChangeInterceptor(); 26 | lci.setParamName("lang"); 27 | return lci; 28 | } 29 | 30 | @Bean 31 | public MessageSource messageSource() { 32 | var messageSource = new ResourceBundleMessageSource(); 33 | messageSource.setBasename("messages"); 34 | messageSource.setDefaultEncoding("UTF-8"); 35 | return messageSource; 36 | } 37 | 38 | @Override 39 | public void addInterceptors(InterceptorRegistry registry) { 40 | registry.addInterceptor(localeChangeInterceptor()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspacesettings/WorkspaceSettings.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspacesettings; 2 | 3 | import io.hexlet.typoreporter.domain.AbstractAuditingEntity; 4 | import io.hexlet.typoreporter.domain.Identifiable; 5 | import io.hexlet.typoreporter.domain.workspace.Workspace; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.JoinColumn; 9 | import jakarta.persistence.MapsId; 10 | import jakarta.persistence.OneToOne; 11 | import jakarta.validation.constraints.NotNull; 12 | import lombok.Data; 13 | import lombok.ToString; 14 | import lombok.experimental.Accessors; 15 | 16 | import java.util.UUID; 17 | 18 | import static jakarta.persistence.FetchType.LAZY; 19 | 20 | @Data 21 | @Accessors(chain = true) 22 | @Entity 23 | public class WorkspaceSettings extends AbstractAuditingEntity implements Identifiable { 24 | 25 | @Id 26 | private Long id; 27 | 28 | @NotNull 29 | private UUID apiAccessToken; 30 | 31 | @MapsId 32 | @OneToOne(fetch = LAZY) 33 | @JoinColumn(name = "id") 34 | @ToString.Exclude 35 | private Workspace workspace; 36 | 37 | @Override 38 | public int hashCode() { 39 | return getClass().hashCode(); 40 | } 41 | 42 | @Override 43 | public boolean equals(Object obj) { 44 | if (obj == this) { 45 | return true; 46 | } 47 | if (obj instanceof WorkspaceSettings workspaceSettings) { 48 | return id != null && id.equals(workspaceSettings.id); 49 | } 50 | return false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/resources/templates/workspace/wks-integration.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 |

13 |
14 | <script>
15 | function loadScript(src, callback) {
16 |   const body = document.querySelector('body')
17 |   const script = document.createElement('script');
18 |   script.src = src;
19 |   script.onload = callback;
20 |   body.appendChild(script);
21 | };
22 | 
23 | document.addEventListener('DOMContentLoaded', function () {
24 |   const scriptSrc = 'https://cdn.jsdelivr.net/gh/hexlet/hexlet-correction@latest/src/widget/index.js?v=' + new Date().getTime();
25 |   loadScript(scriptSrc, function () {
26 |     handleTypoReporter({
27 |       authorizationToken: '[[${wksBasicToken}]]',
28 |       workSpaceUrl: '[[${rootUrl}]]',
29 |       workSpaceId: '[[${wksId}]]'})
30 |   });
31 | });
32 | </script>
33 |                   
34 |
35 |
36 |
37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /src/main/resources/config/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: hexletTypoReporter 4 | jpa: 5 | open-in-view: false 6 | hibernate: 7 | ddl-auto: validate 8 | liquibase: 9 | change-log: classpath:db/changelog/db.changelog-master.xml 10 | mvc: 11 | hiddenmethod: 12 | filter: 13 | enabled: true 14 | problemdetails: 15 | enabled: true 16 | security: 17 | oauth2: 18 | client: 19 | registration: 20 | yandex: 21 | client-id: ${YANDEX_CLIENT_ID} 22 | client-secret: ${YANDEX_CLIENT_SECRET} 23 | redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" 24 | authorization-grant-type: authorization_code 25 | scope: login:email, login:info 26 | client-name: Yandex 27 | github: 28 | client-id: ${GITHUB_CLIENT_ID} 29 | client-secret: ${GITHUB_CLIENT_SECRET} 30 | scope: 31 | - read:user 32 | - user:email 33 | redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" 34 | client-name: GitHub 35 | provider: 36 | yandex: 37 | authorization-uri: https://oauth.yandex.ru/authorize 38 | token-uri: https://oauth.yandex.ru/token 39 | user-info-uri: https://login.yandex.ru/info 40 | user-name-attribute: default_email 41 | github: 42 | authorization-uri: https://github.com/login/oauth/authorize 43 | token-uri: https://github.com/login/oauth/access_token 44 | user-info-uri: https://api.github.com/user 45 | user-name-attribute: login 46 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/AbstractFieldMatchValidator.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto; 2 | 3 | import jakarta.validation.ConstraintValidator; 4 | import jakarta.validation.ConstraintValidatorContext; 5 | import org.springframework.beans.PropertyAccessorFactory; 6 | 7 | import java.lang.annotation.Annotation; 8 | 9 | import static java.lang.String.format; 10 | 11 | public abstract class AbstractFieldMatchValidator implements ConstraintValidator { 12 | protected String firstFieldName; 13 | protected String secondFieldName; 14 | protected String message; 15 | 16 | protected abstract boolean areFieldsValid(Object firstField, Object secondField); 17 | 18 | @Override 19 | public boolean isValid(final Object object, final ConstraintValidatorContext context) { 20 | final var beanWrapper = PropertyAccessorFactory.forBeanPropertyAccess(object); 21 | final var firstObj = beanWrapper.getPropertyValue(firstFieldName); 22 | final var secondObj = beanWrapper.getPropertyValue(secondFieldName); 23 | final var isValid = areFieldsValid(firstObj, secondObj); 24 | 25 | if (!isValid) { 26 | context.disableDefaultConstraintViolation(); 27 | context.buildConstraintViolationWithTemplate(format(message, firstObj, secondObj)) 28 | .addPropertyNode(firstFieldName) 29 | .addConstraintViolation(); 30 | context.buildConstraintViolationWithTemplate(format(message, firstObj, secondObj)) 31 | .addPropertyNode(secondFieldName) 32 | .addConstraintViolation(); 33 | } 34 | return isValid; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/security/service/AccountDetailService.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.security.service; 2 | 3 | import io.hexlet.typoreporter.repository.AccountRepository; 4 | import io.hexlet.typoreporter.service.dto.account.CustomUserDetails; 5 | import io.hexlet.typoreporter.utils.TextUtils; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 | import org.springframework.security.core.userdetails.UserDetails; 10 | import org.springframework.security.core.userdetails.UserDetailsService; 11 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 12 | import org.springframework.stereotype.Service; 13 | 14 | import java.util.Collection; 15 | import java.util.Collections; 16 | 17 | @Service 18 | @RequiredArgsConstructor 19 | public class AccountDetailService implements UserDetailsService { 20 | 21 | private final AccountRepository accountRepository; 22 | 23 | @Override 24 | public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { 25 | final String normalizedEmail = TextUtils.toLowerCaseData(email); 26 | return accountRepository.findAccountByEmail(normalizedEmail) 27 | .map(acc -> { 28 | Collection authorities = 29 | Collections.singletonList(new SimpleGrantedAuthority("USER")); 30 | return new CustomUserDetails(acc.getEmail(), acc.getPassword(), acc.getUsername(), authorities); 31 | }) 32 | .orElseThrow(() -> new UsernameNotFoundException("Account with email='" + email + "' not found")); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/resources/config/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: hexletTypoReporter 4 | jpa: 5 | open-in-view: true 6 | show-sql: true 7 | properties: 8 | hibernate: 9 | generate_statistics: true 10 | use_sql_comments: true 11 | format_sql: true 12 | liquibase: 13 | change-log: classpath:db/changelog/db.changelog-master.xml 14 | mvc: 15 | hiddenmethod: 16 | filter: 17 | enabled: true 18 | problemdetails: 19 | enabled: true 20 | security: 21 | oauth2: 22 | client: 23 | registration: 24 | yandex: 25 | client-id: test-client-id 26 | client-secret: test-client-secret 27 | redirect-uri: http://localhost:8080/login/oauth2/code/yandex 28 | authorization-grant-type: authorization_code 29 | scope: login:email 30 | client-name: Yandex 31 | github: 32 | client-id: test 33 | client-secret: test 34 | redirect-uri: http://localhost:8080/login/oauth2/code/github 35 | scope: read:user 36 | provider: 37 | yandex: 38 | authorization-uri: https://oauth.yandex.ru/authorize 39 | token-uri: https://oauth.yandex.ru/token 40 | user-info-uri: https://login.yandex.ru/info 41 | user-name-attribute: default_email 42 | github: 43 | authorization-uri: https://github.com/login/oauth/authorize 44 | token-uri: https://github.com/login/oauth/access_token 45 | user-info-uri: https://api.github.com/user 46 | user-name-attribute: login 47 | 48 | 49 | logging: 50 | level: 51 | root: INFO 52 | web: DEBUG 53 | org.hibernate.type.descriptor.sql: TRACE 54 | io.hexlet.typoreporter: DEBUG 55 | # Bug in database rider, a lot of log 'warning' 56 | com.github.database.rider.core.api.dataset.ScriptableTable: ERROR 57 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2021/04/20211904104233167-create-workspace-table.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /docs/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | functional: 3 | - Пользователь должен иметь возможность зарегистрироваться в приложении. 4 | - Пользователь должен иметь возможность добавить скрипт на свой сайт. 5 | - На сервисе, в личном аккаунте пользователя, должны отображаться ошибки, которые нашли на сайтах пользователя. 6 | - Пользователь должен иметь возможность отслеживать статус ошибок, найденных на своем сайте, через сервис. #отправлено, в работе,решено, отменено, всего 7 | - Пользователь должен иметь возможность корректировать ошибки через сервис. 8 | - Пользователь должен иметь возможность просматривать пространства, которые он создал. 9 | - Пользователь не должен иметь возможность просматривать пространства, созданные другими пользователями. 10 | - Пользователь должен иметь возможность дать имя пространству. 11 | - Пользователь должен иметь возможность описать пространство. 12 | - Пользователь должен иметь возможность добавлять пользователей в созданное пространство. 13 | - Должна быть возможность просматривать информацию об аккаунте. 14 | - Должна быть возможность изменять информацию об аккаунте #ФИО,почту, пароль 15 | non-functional: 16 | - Сервис должен быть разработан с учетом удобства использования и оптимизирован для скорости работы. 17 | - Сервис должен быть доступен на различных устройствах. 18 | - Сервис должен иметь мобильную версию. 19 | - Сервис должен быть безопасным и защищать данные пользователя. 20 | - Сервис должен быть протестирован на совместимость с последними версиями браузеров. 21 | - Сервис должен должен быть способен обрабатывать большое количество трафика. 22 | - Информация на сайте должна быть представлена на русском и английском языках. 23 | implicit: 24 | - Сервис содержит функцию "теста" #новый пользователь мог бы иметь возможность сам себе отправить ошибку и посмотреть как работает сервис 25 | - Сервис содержит инструкцию по установке скрипта на свой сайт 26 | - Сервис содержит инструкцию по работе в личном кабинете пользователя 27 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/workspace/AllowedUrl.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.workspace; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIdentityInfo; 4 | import com.fasterxml.jackson.annotation.ObjectIdGenerators; 5 | import io.hexlet.typoreporter.domain.workspace.constraint.WorkspaceUrl; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.FetchType; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.ManyToOne; 12 | import jakarta.persistence.SequenceGenerator; 13 | import jakarta.validation.constraints.NotNull; 14 | import lombok.Getter; 15 | import lombok.NoArgsConstructor; 16 | import lombok.Setter; 17 | import lombok.ToString; 18 | import lombok.experimental.Accessors; 19 | 20 | import java.util.Objects; 21 | 22 | @Getter 23 | @Setter 24 | @ToString 25 | @NoArgsConstructor 26 | @Accessors(chain = true) 27 | @Entity 28 | @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") 29 | public class AllowedUrl { 30 | 31 | @Id 32 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "url_id_seq") 33 | @SequenceGenerator(name = "url_id_seq", allocationSize = 15) 34 | private Long id; 35 | 36 | @WorkspaceUrl 37 | private String url; 38 | 39 | @NotNull 40 | @ManyToOne(fetch = FetchType.LAZY) 41 | @ToString.Exclude 42 | private Workspace workspace; 43 | 44 | @Override 45 | public boolean equals(Object o) { 46 | if (this == o) { 47 | return true; 48 | } 49 | if (o == null || getClass() != o.getClass()) { 50 | return false; 51 | } 52 | AllowedUrl that = (AllowedUrl) o; 53 | return Objects.equals(url, that.url) && Objects.equals(workspace, that.workspace); 54 | } 55 | 56 | @Override 57 | public int hashCode() { 58 | return Objects.hash(url, workspace); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 | 10 | 11 |
12 |
13 | 15 | 16 |
17 |
18 | 20 | 21 |
22 | 23 |
24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 | 36 |
37 |
38 | 39 |
40 |
41 |
42 | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bugreport.yml: -------------------------------------------------------------------------------- 1 | name: "Bug Report" 2 | description: Create a new ticket for a bug. 3 | title: "Bug: " 4 | labels: 5 | - bug 6 | 7 | body: 8 | - type: textarea 9 | id: summary 10 | attributes: 11 | label: "Summary" 12 | description: Please enter an explicit description of your issue 13 | placeholder: Short and explicit description of your incident... 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: reprod 19 | attributes: 20 | label: "Reproduction steps" 21 | description: Please enter an explicit description of your issue 22 | placeholder: | 23 | 1. Go to '...' 24 | 2. Click on '....' 25 | 3. Scroll down to '....' 26 | 4. See error 27 | render: bash 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: expected-result 33 | attributes: 34 | label: "Expected result" 35 | description: What the result should have been 36 | placeholder: Expected result 37 | validations: 38 | required: true 39 | 40 | - type: textarea 41 | id: actual-result 42 | attributes: 43 | label: "Actual result" 44 | description: What the result really was 45 | placeholder: Actual result 46 | validations: 47 | required: true 48 | 49 | - type: dropdown 50 | id: browsers 51 | attributes: 52 | label: "Browsers" 53 | description: What browsers are you seeing the problem on ? 54 | multiple: true 55 | options: 56 | - Firefox 57 | - Chrome 58 | - Safari 59 | - Microsoft Edge 60 | - Opera 61 | 62 | validations: 63 | required: false 64 | - type: dropdown 65 | id: os 66 | attributes: 67 | label: "OS" 68 | description: What is the impacted environment ? 69 | multiple: true 70 | options: 71 | - Windows 72 | - Linux 73 | - Mac 74 | validations: 75 | required: false 76 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/dto/FieldMatchIgnoreCase.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.dto; 2 | 3 | import jakarta.validation.Constraint; 4 | import jakarta.validation.Payload; 5 | 6 | import java.lang.annotation.Documented; 7 | import java.lang.annotation.Retention; 8 | import java.lang.annotation.Target; 9 | 10 | import static java.lang.annotation.ElementType.ANNOTATION_TYPE; 11 | import static java.lang.annotation.ElementType.TYPE; 12 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 13 | 14 | /** 15 | * Validation annotation to validate that 2 fields have the same value. 16 | * An array of fields and their matching confirmation fields can be supplied. 17 | *
18 | * Example, compare 1 pair of fields: 19 | *
20 | * {@code @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match")} 21 | *
22 | * Example, compare more than 1 pair of fields: 23 | *
24 | * {@code @FieldMatchConsiderCase( 25 | * first = "password", 26 | * second = "confirmPassword", 27 | * message = "The password and it confirmation must match")} 28 | */ 29 | @Target({TYPE, ANNOTATION_TYPE}) 30 | @Retention(RUNTIME) 31 | @Constraint(validatedBy = FieldMatchIgnoreCaseValidator.class) 32 | @Documented 33 | public @interface FieldMatchIgnoreCase { 34 | 35 | String message() default "The {first} and {second} fields must be equal"; 36 | 37 | Class[] groups() default {}; 38 | 39 | Class[] payload() default {}; 40 | 41 | /** 42 | * @return The first field 43 | */ 44 | String first(); 45 | 46 | /** 47 | * @return The second field 48 | */ 49 | String second(); 50 | 51 | /** 52 | * Defines several @FieldMatch annotations on the same element 53 | * 54 | * @see FieldMatchIgnoreCase 55 | */ 56 | @Target({TYPE, ANNOTATION_TYPE}) 57 | @Retention(RUNTIME) 58 | @Documented 59 | @interface List { 60 | 61 | FieldMatchIgnoreCase[] value(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/resources/templates/workspaces.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |

11 | [[#{text.hero-header}]] 12 | 13 |

14 |
15 |
16 |

17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |

29 |
30 | 35 |
36 |
37 |
38 |
> 39 | 40 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/web/WorkspaceApi.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.web; 2 | 3 | import io.hexlet.typoreporter.service.TypoService; 4 | import io.hexlet.typoreporter.service.dto.typo.ReportedTypo; 5 | import io.hexlet.typoreporter.service.dto.typo.TypoReport; 6 | import io.hexlet.typoreporter.handler.exception.WorkspaceNotFoundException; 7 | import jakarta.validation.Valid; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.security.core.Authentication; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.PostMapping; 13 | import org.springframework.web.bind.annotation.RequestBody; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | import org.springframework.web.util.UriComponentsBuilder; 17 | 18 | import static org.springframework.http.ResponseEntity.created; 19 | 20 | @RestController 21 | @RequestMapping("/api/workspaces") 22 | @RequiredArgsConstructor 23 | public class WorkspaceApi { 24 | 25 | private final TypoService service; 26 | 27 | @PostMapping("/{id}/typos") 28 | public ResponseEntity addTypoReport(@PathVariable long id, 29 | Authentication authentication, 30 | @Valid @RequestBody TypoReport typoReport, 31 | UriComponentsBuilder builder) { 32 | final var wksIdStr = authentication.getName(); 33 | 34 | try { 35 | final var authId = Long.parseLong(wksIdStr); 36 | if (authId != id) { 37 | throw new WorkspaceNotFoundException(id); 38 | } 39 | final var uri = builder.path("/workspace").pathSegment(wksIdStr).path("/typos").build().toUri(); 40 | return created(uri).body(service.addTypoReport(typoReport, id)); 41 | } catch (NumberFormatException e) { 42 | throw new WorkspaceNotFoundException(wksIdStr, e); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/resources/templates/widget/typo-form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 |
12 | 19 | 20 |
21 |

22 |
23 |
24 | 25 |
26 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/db.changelog-master.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/oauth2/GithubOAuth2UserInfo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.oauth2; 2 | 3 | import org.springframework.core.ParameterizedTypeReference; 4 | import org.springframework.http.HttpHeaders; 5 | import org.springframework.web.reactive.function.client.WebClient; 6 | 7 | import java.util.List; 8 | import java.util.Map; 9 | 10 | public class GithubOAuth2UserInfo implements OAuth2UserInfo { 11 | 12 | private final String accessToken; 13 | private final Map attributes; 14 | 15 | public GithubOAuth2UserInfo(String accessToken, Map attributes) { 16 | this.accessToken = accessToken; 17 | this.attributes = attributes; 18 | } 19 | 20 | @Override 21 | public String getEmail() { 22 | var email = attributes.get("email"); 23 | if (email == null) { 24 | WebClient webClient = WebClient.builder() 25 | .baseUrl("https://api.github.com") 26 | .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) 27 | .build(); 28 | 29 | List> emails = webClient.get() 30 | .uri("/user/emails") 31 | .retrieve() 32 | .bodyToMono(new ParameterizedTypeReference>>() { }) 33 | .block(); 34 | 35 | email = emails.stream() 36 | .filter(e -> Boolean.TRUE.equals(e.get("primary"))) 37 | .map(e -> (String) e.get("email")) 38 | .findFirst() 39 | .orElse(null); 40 | } 41 | return (String) email; 42 | } 43 | 44 | @Override 45 | public String getUsername() { 46 | return attributes.get("login").toString(); 47 | } 48 | 49 | @Override 50 | public String getFirstName() { 51 | String[] names = attributes.get("name").toString().split(" "); 52 | return names.length > 0 ? names[0] : ""; 53 | } 54 | 55 | @Override 56 | public String getLastName() { 57 | String[] names = attributes.get("name").toString().split(" "); 58 | return names.length > 1 ? names[1] : ""; 59 | } 60 | 61 | @Override 62 | public Map getAttributes() { 63 | return attributes; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/resources/templates/create-workspace.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | 15 | 16 |
17 |
18 |
19 | 23 | 24 |
25 |
26 |
27 | 29 | 30 |
32 |
33 | 34 |
35 |
36 |
37 |
38 | 39 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2022/12/2022-12-08-create-workspace-settings-table.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/controller/TypoController.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.controller; 2 | 3 | import io.hexlet.typoreporter.domain.typo.TypoEvent; 4 | import io.hexlet.typoreporter.service.TypoService; 5 | import io.hexlet.typoreporter.service.WorkspaceService; 6 | import io.hexlet.typoreporter.handler.exception.TypoNotFoundException; 7 | import io.hexlet.typoreporter.handler.exception.WorkspaceNotFoundException; 8 | import lombok.RequiredArgsConstructor; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.stereotype.Controller; 11 | import org.springframework.web.bind.annotation.PatchMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RequestParam; 15 | 16 | import java.util.Optional; 17 | 18 | //TODO add tests 19 | @Slf4j 20 | @Controller 21 | @RequestMapping("/typos") 22 | @RequiredArgsConstructor 23 | public class TypoController { 24 | 25 | private final TypoService typoService; 26 | 27 | private final WorkspaceService workspaceService; 28 | 29 | @PatchMapping("/{id}/status") 30 | public String updateTypoStatus(@PathVariable Long id, 31 | @RequestParam Optional wksId, 32 | @RequestParam Optional event, 33 | @RequestParam Optional next) { 34 | if (wksId.isEmpty() || !workspaceService.existsWorkspaceById(wksId.get())) { 35 | //TODO send to error page 36 | final var e = new WorkspaceNotFoundException(wksId.orElse(0L)); 37 | log.error(e.toString(), e); 38 | return "redirect:/workspaces"; 39 | } 40 | if (event.isEmpty()) { 41 | //TODO send to error page 42 | log.error("TypoEvent={} must not be null", event.orElse(null)); 43 | return ("redirect:/workspace/") + wksId.get() + next.orElse("/typos"); 44 | } 45 | final var updatedTypo = typoService.updateTypoStatus(id, event.get()); 46 | if (updatedTypo.isEmpty()) { 47 | //TODO send to error page 48 | final var e = new TypoNotFoundException(id); 49 | log.error(e.getMessage(), e); 50 | } 51 | return ("redirect:/workspace/") + wksId.get() + next.orElse("/typos"); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter; 2 | 3 | import io.github.cdimascio.dotenv.Dotenv; 4 | import io.github.cdimascio.dotenv.DotenvEntry; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.core.env.Environment; 9 | 10 | import java.net.InetAddress; 11 | import java.net.UnknownHostException; 12 | import java.util.Set; 13 | 14 | import static java.util.Optional.ofNullable; 15 | 16 | @Slf4j 17 | @SpringBootApplication 18 | public class HexletTypoReporter { 19 | 20 | public static void main(String[] args) { 21 | Dotenv dotenv = Dotenv.configure() 22 | .ignoreIfMalformed() 23 | .ignoreIfMissing() 24 | .load(); 25 | 26 | Set dotenvInFile = dotenv.entries(Dotenv.Filter.DECLARED_IN_ENV_FILE); 27 | dotenvInFile.forEach(entry -> 28 | System.setProperty(entry.getKey(), entry.getValue())); 29 | 30 | final var env = SpringApplication.run(HexletTypoReporter.class, args).getEnvironment(); 31 | logApplicationStartup(env); 32 | } 33 | 34 | private static void logApplicationStartup(Environment env) { 35 | final var protocol = "http"; 36 | final var port = ofNullable(env.getProperty("server.port")).orElse("8080"); 37 | final var contextPath = ofNullable(env.getProperty("server.servlet.context-path")) 38 | .filter(String::isBlank) 39 | .orElse("/"); 40 | var hostAddress = "localhost"; 41 | try { 42 | hostAddress = InetAddress.getLocalHost().getHostAddress(); 43 | } catch (UnknownHostException e) { 44 | log.warn("The host name could not be determined, using `localhost` as fallback"); 45 | } 46 | final var msg = """ 47 | 48 | ---------------------------------------------------------- 49 | Application '{}' is running! Access URLs: 50 | Local: {}://localhost:{}{} 51 | External: {}://{}:{}{} 52 | Profile(s): {} 53 | ---------------------------------------------------------- 54 | """; 55 | final var appName = env.getProperty("spring.application.name"); 56 | log.info(msg, appName, protocol, port, contextPath, 57 | protocol, hostAddress, port, contextPath, env.getActiveProfiles()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/config/DynamicCorsConfigurationSource.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.config; 2 | 3 | import io.hexlet.typoreporter.handler.exception.ForbiddenDomainException; 4 | import io.hexlet.typoreporter.handler.exception.WorkspaceNotFoundException; 5 | import io.hexlet.typoreporter.repository.AllowedUrlRepository; 6 | import io.hexlet.typoreporter.utils.TextUtils; 7 | import lombok.AllArgsConstructor; 8 | import org.springframework.stereotype.Component; 9 | import jakarta.servlet.http.HttpServletRequest; 10 | import org.springframework.util.AntPathMatcher; 11 | import org.springframework.web.cors.CorsConfiguration; 12 | import org.springframework.web.cors.CorsConfigurationSource; 13 | 14 | import java.util.Arrays; 15 | import java.util.List; 16 | import java.util.stream.Collectors; 17 | 18 | @Component 19 | @AllArgsConstructor 20 | public class DynamicCorsConfigurationSource implements CorsConfigurationSource { 21 | 22 | private final AllowedUrlRepository urlRepository; 23 | private final AntPathMatcher matcher = new AntPathMatcher(); 24 | 25 | @Override 26 | public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { 27 | String pattern = "/api/workspaces/{id}/typos"; 28 | 29 | String referer = request.getHeader("Referer"); 30 | Long wksId = Long.parseLong(matcher.extractUriTemplateVariables(pattern, request.getRequestURI()).get("id")); 31 | 32 | if (wksId != null && referer != null) { 33 | String refererDomain = TextUtils.trimUrl(referer); 34 | List allowedUrls = urlRepository.findByWorkspaceId(wksId).stream() 35 | .map(url -> url.getUrl()).collect(Collectors.toList()); 36 | 37 | if (!allowedUrls.isEmpty() && allowedUrls.contains(refererDomain)) { 38 | CorsConfiguration config = new CorsConfiguration(); 39 | 40 | config.setAllowedOrigins(allowedUrls); 41 | config.setAllowedMethods(Arrays.asList("GET", "POST")); 42 | config.setAllowedHeaders(Arrays.asList("Content-Type", "Authorization", "secretKey")); 43 | config.setAllowCredentials(true); 44 | 45 | return config; 46 | } else if (allowedUrls.isEmpty()) { 47 | throw new WorkspaceNotFoundException(wksId); 48 | } else { 49 | throw new ForbiddenDomainException(refererDomain); 50 | } 51 | } else if (referer == null) { 52 | throw new ForbiddenDomainException("null"); 53 | } 54 | 55 | return null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/acc-info.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 |
57 | 58 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2021/04/20211904104243755-create-typo-table.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | CREATE TYPE TYPO_STATUS AS ENUM ('REPORTED','IN_PROGRESS','RESOLVED','CANCELED') 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/pass-update.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | 14 | 15 |
16 |
17 |
18 | 21 | 22 |
23 |
24 |
25 | 28 | 29 |
30 |
31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 |
39 |
40 | 41 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2Service.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service.oauth2; 2 | 3 | import io.hexlet.typoreporter.domain.account.AuthProvider; 4 | import io.hexlet.typoreporter.service.AccountService; 5 | import io.hexlet.typoreporter.service.account.signup.SignupAccount; 6 | import io.hexlet.typoreporter.utils.TextUtils; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; 9 | import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; 10 | import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; 11 | import org.springframework.security.oauth2.core.OAuth2AuthenticationException; 12 | import org.springframework.security.oauth2.core.user.OAuth2User; 13 | import org.springframework.stereotype.Service; 14 | 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | 18 | @Service 19 | @RequiredArgsConstructor 20 | public class OAuth2Service implements OAuth2UserService { 21 | 22 | private final AccountService accountService; 23 | 24 | @Override 25 | public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { 26 | 27 | OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest); 28 | String oAuth2Provider = userRequest.getClientRegistration().getRegistrationId().toUpperCase(); 29 | String accessToken = userRequest.getAccessToken().getTokenValue(); 30 | 31 | OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo( 32 | oAuth2Provider, accessToken, new HashMap<>(oAuth2User.getAttributes())); 33 | 34 | String email = oAuth2UserInfo.getEmail(); 35 | 36 | if (email == null) { 37 | throw new OAuth2AuthenticationException("Email from provider " + oAuth2Provider + " not received"); 38 | } 39 | 40 | String normalizedEmail = TextUtils.toLowerCaseData(email); 41 | 42 | if (!accountService.existsByEmail(normalizedEmail)) { 43 | var newAccount = new SignupAccount( 44 | oAuth2UserInfo.getUsername(), 45 | normalizedEmail, 46 | "OAUTH2_USER", 47 | oAuth2UserInfo.getFirstName(), 48 | oAuth2UserInfo.getLastName(), 49 | AuthProvider.valueOf(oAuth2Provider).name() 50 | ); 51 | accountService.signup(newAccount); 52 | } 53 | 54 | Map oAuth2UserAttributes = oAuth2UserInfo.getAttributes(); 55 | oAuth2UserAttributes.putIfAbsent("email", normalizedEmail); 56 | 57 | return new CustomOAuth2User( 58 | oAuth2User.getAuthorities(), 59 | oAuth2UserAttributes, 60 | "email", 61 | oAuth2UserInfo.getUsername() 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/handler/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.handler; 2 | 3 | import io.hexlet.typoreporter.handler.exception.AccountAlreadyExistException; 4 | import io.hexlet.typoreporter.handler.exception.AccountNotFoundException; 5 | import io.hexlet.typoreporter.handler.exception.NewPasswordTheSameException; 6 | import io.hexlet.typoreporter.handler.exception.OldPasswordWrongException; 7 | import io.hexlet.typoreporter.handler.exception.WorkspaceAlreadyExistException; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.web.bind.annotation.ControllerAdvice; 12 | import org.springframework.web.bind.annotation.ExceptionHandler; 13 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 14 | 15 | import javax.security.auth.login.AccountExpiredException; 16 | 17 | @Slf4j 18 | @ControllerAdvice 19 | public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 20 | 21 | @ExceptionHandler(AccountNotFoundException.class) 22 | public ResponseEntity handleResourceNotFoundException(AccountNotFoundException ex) { 23 | return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); 24 | } 25 | 26 | @ExceptionHandler(AccountAlreadyExistException.class) 27 | public ResponseEntity handleAccountAlreadyExistException(AccountAlreadyExistException ex) { 28 | return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage()); 29 | } 30 | 31 | @ExceptionHandler(AccountExpiredException.class) 32 | public ResponseEntity handleAccountExpiredException(AccountExpiredException ex) { 33 | return ResponseEntity.status(HttpStatus.GONE).body(ex.getMessage()); 34 | } 35 | 36 | @ExceptionHandler(NewPasswordTheSameException.class) 37 | public ResponseEntity handleNewPasswordSameException(NewPasswordTheSameException e) { 38 | return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); 39 | } 40 | 41 | @ExceptionHandler(OldPasswordWrongException.class) 42 | public ResponseEntity handleOldPasswordWrongException(OldPasswordWrongException ex) { 43 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); 44 | } 45 | 46 | @ExceptionHandler(TypeNotPresentException.class) 47 | public ResponseEntity handleTypoNotFoundException(TypeNotPresentException ex) { 48 | return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); 49 | } 50 | 51 | @ExceptionHandler(WorkspaceAlreadyExistException.class) 52 | public ResponseEntity handleWorkSpaceAlreadyExistException(WorkspaceAlreadyExistException ex) { 53 | return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/service/WorkspaceSettingsServiceIT.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service; 2 | 3 | 4 | import com.github.database.rider.core.api.configuration.DBUnit; 5 | import com.github.database.rider.core.api.dataset.DataSet; 6 | import com.github.database.rider.spring.api.DBRider; 7 | import org.junit.jupiter.params.ParameterizedTest; 8 | import org.junit.jupiter.params.provider.MethodSource; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.test.context.DynamicPropertyRegistry; 12 | import org.springframework.test.context.DynamicPropertySource; 13 | import org.springframework.transaction.annotation.Transactional; 14 | import org.testcontainers.containers.PostgreSQLContainer; 15 | import org.testcontainers.junit.jupiter.Container; 16 | import org.testcontainers.junit.jupiter.Testcontainers; 17 | import java.util.UUID; 18 | import static com.github.database.rider.core.api.configuration.Orthography.LOWERCASE; 19 | import static io.hexlet.typoreporter.test.Constraints.POSTGRES_IMAGE; 20 | import static org.assertj.core.api.Assertions.assertThat; 21 | 22 | @SpringBootTest 23 | @Testcontainers 24 | @Transactional 25 | @DBRider 26 | @DBUnit(caseInsensitiveStrategy = LOWERCASE, cacheConnection = false) 27 | @DataSet(value = {"workspaces.yml", "workspace_settings.yml"}) 28 | public class WorkspaceSettingsServiceIT { 29 | 30 | @Container 31 | static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(POSTGRES_IMAGE) 32 | .withPassword("inmemory") 33 | .withUsername("inmemory"); 34 | 35 | @DynamicPropertySource 36 | static void datasourceProperties(DynamicPropertyRegistry registry) { 37 | registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); 38 | registry.add("spring.datasource.password", postgreSQLContainer::getPassword); 39 | registry.add("spring.datasource.username", postgreSQLContainer::getUsername); 40 | } 41 | 42 | @Autowired 43 | private WorkspaceSettingsService service; 44 | 45 | @ParameterizedTest 46 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getWorkspaceIdsExist") 47 | void getWorkspaceApiAccessTokenByIdIsSuccessful(final Long wksId) { 48 | UUID uuid = service.getWorkspaceApiAccessTokenById(wksId); 49 | assertThat(uuid != null).isTrue(); 50 | } 51 | 52 | @ParameterizedTest 53 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getWorkspaceIdsExist") 54 | void regenerateWorkspaceApiAccessTokenByIdIsSuccessful(final Long wksId) { 55 | UUID previousUuid = service.getWorkspaceApiAccessTokenById(wksId); 56 | service.regenerateWorkspaceApiAccessTokenById(wksId); 57 | UUID newUuid = service.getWorkspaceApiAccessTokenById(wksId); 58 | assertThat(previousUuid).isNotEqualTo(newUuid); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/prof-update.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | 14 | 15 |
16 |
17 |
18 | 21 | 22 |
23 |
24 |
25 | 28 | 29 |
30 |
31 |
32 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 |
43 |
44 | 45 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/service/WorkspaceRoleService.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.service; 2 | 3 | import io.hexlet.typoreporter.domain.account.Account; 4 | import io.hexlet.typoreporter.domain.workspace.WorkspaceRole; 5 | import io.hexlet.typoreporter.domain.workspace.WorkspaceRoleId; 6 | import io.hexlet.typoreporter.repository.AccountRepository; 7 | import io.hexlet.typoreporter.repository.WorkspaceRepository; 8 | import io.hexlet.typoreporter.repository.WorkspaceRoleRepository; 9 | import io.hexlet.typoreporter.handler.exception.AccountNotFoundException; 10 | import io.hexlet.typoreporter.handler.exception.WorkspaceNotFoundException; 11 | import io.hexlet.typoreporter.handler.exception.WorkspaceRoleNotFoundException; 12 | import lombok.RequiredArgsConstructor; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | 17 | import static io.hexlet.typoreporter.domain.workspace.AccountRole.ROLE_ANONYMOUS; 18 | 19 | @Service 20 | @RequiredArgsConstructor 21 | public class WorkspaceRoleService { 22 | 23 | private final WorkspaceRoleRepository workspaceRoleRepository; 24 | 25 | private final WorkspaceRepository workspaceRepository; 26 | 27 | private final AccountRepository accountRepository; 28 | 29 | @Transactional 30 | public WorkspaceRole addAccountToWorkspace(Long wksId, String accEmail) { 31 | 32 | final var accId = accountRepository.findAccountByEmail(accEmail) 33 | .map(Account::getId) 34 | .orElseThrow(() -> new AccountNotFoundException(accEmail)); 35 | 36 | if (!workspaceRepository.existsWorkspaceById(wksId)) { 37 | throw new WorkspaceNotFoundException(wksId); 38 | } 39 | 40 | final var workspaceRole = new WorkspaceRole( 41 | new WorkspaceRoleId(wksId, accId), 42 | ROLE_ANONYMOUS, 43 | workspaceRepository.getReferenceById(wksId), 44 | accountRepository.getReferenceById(accId) 45 | ); 46 | return workspaceRoleRepository.save(workspaceRole); 47 | } 48 | 49 | @Transactional 50 | public void deleteAccountFromWorkspace(Long wksId, String accountEmail) { 51 | final var account = accountRepository.findAccountByEmail(accountEmail) 52 | .orElseThrow(() -> new AccountNotFoundException(accountEmail)); 53 | final var workspace = workspaceRepository.getWorkspaceById(wksId) 54 | .orElseThrow(() -> new WorkspaceNotFoundException(wksId)); 55 | final var beingDeleteRole = workspaceRoleRepository.getWorkspaceRoleByAccountIdAndWorkspaceId( 56 | account.getId(), 57 | wksId) 58 | .orElseThrow(() -> new WorkspaceRoleNotFoundException(account.getId(), wksId)); 59 | account.removeWorkSpaceRole(beingDeleteRole); 60 | workspace.removeWorkSpaceRole(beingDeleteRole); 61 | accountRepository.save(account); 62 | workspaceRepository.save(workspace); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/TypoStatus.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo; 2 | 3 | 4 | import java.util.Collection; 5 | import java.util.List; 6 | 7 | import static io.hexlet.typoreporter.domain.typo.TypoEvent.CANCEL; 8 | import static io.hexlet.typoreporter.domain.typo.TypoEvent.OPEN; 9 | import static io.hexlet.typoreporter.domain.typo.TypoEvent.REOPEN; 10 | import static io.hexlet.typoreporter.domain.typo.TypoEvent.RESOLVE; 11 | 12 | 13 | public enum TypoStatus { 14 | 15 | REPORTED { 16 | @Override 17 | public TypoStatus next(TypoEvent event) { 18 | return switch (event) { 19 | case OPEN -> IN_PROGRESS; 20 | case RESOLVE, REOPEN -> throw new InvalidTypoEventException(this, event); 21 | case CANCEL -> CANCELED; 22 | }; 23 | } 24 | 25 | @Override 26 | public Collection getValidEvents() { 27 | return List.of(OPEN, CANCEL); 28 | } 29 | }, 30 | IN_PROGRESS { 31 | @Override 32 | public TypoStatus next(TypoEvent event) { 33 | return switch (event) { 34 | case OPEN, REOPEN -> throw new InvalidTypoEventException(this, event); 35 | case RESOLVE -> RESOLVED; 36 | case CANCEL -> CANCELED; 37 | }; 38 | } 39 | 40 | @Override 41 | public Collection getValidEvents() { 42 | return List.of(RESOLVE, CANCEL); 43 | } 44 | 45 | @Override 46 | public String toString() { 47 | return "IN PROGRESS"; 48 | } 49 | }, 50 | RESOLVED { 51 | @Override 52 | public TypoStatus next(TypoEvent event) { 53 | return switch (event) { 54 | case REOPEN -> IN_PROGRESS; 55 | case RESOLVE, OPEN -> throw new InvalidTypoEventException(this, event); 56 | case CANCEL -> CANCELED; 57 | }; 58 | } 59 | 60 | @Override 61 | public Collection getValidEvents() { 62 | return List.of(REOPEN, CANCEL); 63 | } 64 | }, 65 | CANCELED { 66 | @Override 67 | public TypoStatus next(TypoEvent event) { 68 | return switch (event) { 69 | case REOPEN -> IN_PROGRESS; 70 | case RESOLVE, OPEN, CANCEL -> throw new InvalidTypoEventException(this, event); 71 | }; 72 | } 73 | 74 | @Override 75 | public Collection getValidEvents() { 76 | return List.of(REOPEN); 77 | } 78 | }; 79 | 80 | public String getStyle() { 81 | return switch (this) { 82 | case REPORTED -> "danger"; 83 | case IN_PROGRESS -> "warning"; 84 | case RESOLVED -> "success"; 85 | case CANCELED -> "secondary"; 86 | }; 87 | } 88 | 89 | public abstract TypoStatus next(TypoEvent event); 90 | 91 | public abstract Collection getValidEvents(); 92 | } 93 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/domain/typo/Typo.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain.typo; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIdentityInfo; 4 | import com.fasterxml.jackson.annotation.ObjectIdGenerators; 5 | import io.hexlet.typoreporter.domain.AbstractAuditingEntity; 6 | import io.hexlet.typoreporter.domain.Identifiable; 7 | import io.hexlet.typoreporter.domain.account.Account; 8 | import io.hexlet.typoreporter.domain.typo.constraint.ReporterComment; 9 | import io.hexlet.typoreporter.domain.typo.constraint.ReporterName; 10 | import io.hexlet.typoreporter.domain.typo.constraint.TextAfterTypo; 11 | import io.hexlet.typoreporter.domain.typo.constraint.TextBeforeTypo; 12 | import io.hexlet.typoreporter.domain.typo.constraint.TextTypo; 13 | import io.hexlet.typoreporter.domain.typo.constraint.TypoPageUrl; 14 | import io.hexlet.typoreporter.domain.workspace.Workspace; 15 | import io.hypersistence.utils.hibernate.type.basic.PostgreSQLEnumType; 16 | import jakarta.persistence.Column; 17 | import jakarta.persistence.Entity; 18 | import jakarta.persistence.EnumType; 19 | import jakarta.persistence.Enumerated; 20 | import jakarta.persistence.FetchType; 21 | import jakarta.persistence.GeneratedValue; 22 | import jakarta.persistence.GenerationType; 23 | import jakarta.persistence.Id; 24 | import jakarta.persistence.ManyToOne; 25 | import jakarta.persistence.SequenceGenerator; 26 | import jakarta.validation.constraints.NotNull; 27 | import lombok.Getter; 28 | import lombok.NoArgsConstructor; 29 | import lombok.Setter; 30 | import lombok.ToString; 31 | import lombok.experimental.Accessors; 32 | import org.hibernate.annotations.Type; 33 | 34 | @Getter 35 | @Setter 36 | @ToString 37 | @NoArgsConstructor 38 | @Accessors(chain = true) 39 | @Entity 40 | @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") 41 | public class Typo extends AbstractAuditingEntity implements Identifiable { 42 | 43 | @Id 44 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "typo_id_seq") 45 | @SequenceGenerator(name = "typo_id_seq", allocationSize = 15) 46 | private Long id; 47 | 48 | @TypoPageUrl 49 | private String pageUrl; 50 | 51 | @ReporterName 52 | private String reporterName; 53 | 54 | @ReporterComment 55 | private String reporterComment; 56 | 57 | @TextBeforeTypo 58 | private String textBeforeTypo; 59 | 60 | @TextTypo 61 | private String textTypo; 62 | 63 | @TextAfterTypo 64 | private String textAfterTypo; 65 | 66 | @NotNull 67 | @Enumerated(EnumType.STRING) 68 | @Column(columnDefinition = "TYPO_STATUS") 69 | @Type(PostgreSQLEnumType.class) 70 | private TypoStatus typoStatus = TypoStatus.REPORTED; 71 | 72 | @NotNull 73 | @ManyToOne(fetch = FetchType.LAZY) 74 | @ToString.Exclude 75 | private Workspace workspace; 76 | 77 | @ManyToOne(fetch = FetchType.LAZY) 78 | @ToString.Exclude 79 | private Account account; 80 | 81 | @Override 82 | public int hashCode() { 83 | return 31; 84 | } 85 | 86 | @Override 87 | public boolean equals(Object obj) { 88 | return this == obj || id != null && obj instanceof Typo other && id.equals(other.id); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/repository/AccountRepositoryIT.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import com.github.database.rider.core.api.configuration.DBUnit; 4 | import com.github.database.rider.core.api.dataset.DataSet; 5 | import com.github.database.rider.spring.api.DBRider; 6 | import io.hexlet.typoreporter.config.audit.AuditConfiguration; 7 | import io.hexlet.typoreporter.domain.account.Account; 8 | import io.hexlet.typoreporter.test.DBUnitEnumPostgres; 9 | import org.junit.jupiter.params.ParameterizedTest; 10 | import org.junit.jupiter.params.provider.MethodSource; 11 | import org.junit.jupiter.params.provider.NullAndEmptySource; 12 | import org.junit.jupiter.params.provider.ValueSource; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; 15 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 16 | import org.springframework.context.annotation.Import; 17 | import org.springframework.test.context.DynamicPropertyRegistry; 18 | import org.springframework.test.context.DynamicPropertySource; 19 | import org.springframework.transaction.annotation.Transactional; 20 | import org.testcontainers.containers.PostgreSQLContainer; 21 | import org.testcontainers.junit.jupiter.Container; 22 | import org.testcontainers.junit.jupiter.Testcontainers; 23 | 24 | import static com.github.database.rider.core.api.configuration.Orthography.LOWERCASE; 25 | import static io.hexlet.typoreporter.test.Constraints.POSTGRES_IMAGE; 26 | import static org.assertj.core.api.Assertions.assertThat; 27 | 28 | @DataJpaTest 29 | @Testcontainers 30 | @Import(AuditConfiguration.class) 31 | @Transactional 32 | @DBRider 33 | @DBUnit(caseInsensitiveStrategy = LOWERCASE, dataTypeFactoryClass = DBUnitEnumPostgres.class, cacheConnection = false) 34 | @DataSet(value = {"accounts.yml", "workspaces.yml", "workspaceRoles.yml"}) 35 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 36 | public class AccountRepositoryIT { 37 | 38 | @Container 39 | public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(POSTGRES_IMAGE) 40 | .withPassword("inmemory") 41 | .withUsername("inmemory"); 42 | 43 | @Autowired 44 | private AccountRepository accountRepository; 45 | 46 | @DynamicPropertySource 47 | static void datasourceProperties(DynamicPropertyRegistry registry) { 48 | registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); 49 | registry.add("spring.datasource.password", postgreSQLContainer::getPassword); 50 | registry.add("spring.datasource.username", postgreSQLContainer::getUsername); 51 | } 52 | 53 | @ParameterizedTest 54 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getAccountEmailExist") 55 | void getAccountByEmail(final String email) { 56 | final var account = accountRepository.findAccountByEmail(email); 57 | assertThat(account).isNotEmpty(); 58 | assertThat(account.map(Account::getEmail).orElseThrow()).isEqualTo(email); 59 | } 60 | 61 | @ParameterizedTest 62 | @NullAndEmptySource 63 | @ValueSource(strings = "invalid-email") 64 | void getAccountByEmailNotExist(final String email) { 65 | assertThat(accountRepository.findAccountByEmail(email)).isEmpty(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | 15 | 16 |
17 |

18 |
19 |
20 | 21 |
22 | 26 | 27 |
28 |

29 |
30 |
31 | 32 |
33 | 35 | 36 |
37 |

38 |
39 |
40 | 41 |
42 | 44 | 45 |
46 |

47 |
48 |
49 | 50 |
51 |
52 |
53 |
54 |
55 | 56 |
57 |
58 | 63 |
64 | 65 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changesets/2022/04/20220428163604-create-account-table.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | CREATE TYPE AUTH_PROVIDER AS ENUM ('EMAIL','GITHUB','GOOGLE') 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 64 | 65 | 66 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/main/java/io/hexlet/typoreporter/controller/WidgetController.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.controller; 2 | 3 | import io.hexlet.typoreporter.service.TypoService; 4 | import io.hexlet.typoreporter.service.WorkspaceService; 5 | import io.hexlet.typoreporter.service.dto.typo.TypoReport; 6 | import jakarta.servlet.http.HttpServletResponse; 7 | import jakarta.validation.Valid; 8 | import lombok.RequiredArgsConstructor; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.stereotype.Controller; 11 | import org.springframework.ui.Model; 12 | import org.springframework.validation.BindingResult; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.ModelAttribute; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.PostMapping; 17 | 18 | @Slf4j 19 | @Controller 20 | @RequiredArgsConstructor 21 | public class WidgetController { 22 | 23 | private final WorkspaceService workspaceService; 24 | 25 | private final TypoService typoService; 26 | 27 | @GetMapping("/typo/form/{wksId}") 28 | String getWidgetTypoForm(HttpServletResponse response, 29 | final Model model, 30 | @PathVariable Long wksId) { 31 | final var wks = workspaceService.getWorkspaceById(wksId); 32 | if (wks.isEmpty()) { 33 | log.error("Error during sending widget typo form. Workspace not found"); 34 | return "widget/report-typo-error"; 35 | } 36 | final var workspace = wks.get(); 37 | 38 | response.addHeader("Content-Security-Policy", "frame-ancestors " + workspace.getUrl()); 39 | model.addAttribute("trustedOrigin", workspace.getUrl()); 40 | 41 | model.addAttribute("wksId", wksId); 42 | model.addAttribute("formModified", false); 43 | model.addAttribute("typoReport", TypoReport.empty()); 44 | 45 | log.info("Send widget typo form to '{}'", workspace.getUrl()); 46 | return "widget/typo-form"; 47 | } 48 | 49 | @PostMapping("/typo/form/{wksId}") 50 | String postWidgetTypoForm(HttpServletResponse response, 51 | Model model, 52 | @Valid @ModelAttribute TypoReport typoReport, 53 | BindingResult bindingResult, 54 | @PathVariable Long wksId) { 55 | final var wks = workspaceService.getWorkspaceById(wksId); 56 | 57 | if (wks.isEmpty()) { 58 | log.error("Error during saving typo from widget. Workspace not found"); 59 | return "widget/report-typo-error"; 60 | } 61 | 62 | response.addHeader("Content-Security-Policy", "frame-ancestors " + wks.get().getUrl()); 63 | 64 | if (bindingResult.hasFieldErrors("reporterComment")) { 65 | log.warn("Validation error during saving typo from widget. Typo not valid. Errors: {}", 66 | bindingResult.getAllErrors()); 67 | model.addAttribute("typoReport", typoReport); 68 | model.addAttribute("formModified", true); 69 | return "widget/typo-form"; 70 | } 71 | 72 | if (bindingResult.hasErrors()) { 73 | log.error("Validation error during saving typo from widget. Typo not valid. Errors: {}", 74 | bindingResult.getAllErrors()); 75 | return "widget/report-typo-error"; 76 | } 77 | 78 | try { 79 | typoService.addTypoReport(typoReport, wksId); 80 | } catch (Exception e) { 81 | log.error("Error during saving typo from widget.", e); 82 | return "widget/report-typo-error"; 83 | } 84 | 85 | return "widget/report-typo-success"; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/resources/templates/fragments/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/domain/EntityTest.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.domain; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import io.hexlet.typoreporter.domain.account.Account; 5 | import io.hexlet.typoreporter.domain.typo.Typo; 6 | import io.hexlet.typoreporter.domain.workspace.Workspace; 7 | import org.junit.jupiter.params.ParameterizedTest; 8 | import org.junit.jupiter.params.provider.MethodSource; 9 | import org.junit.jupiter.params.provider.ValueSource; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.json.JsonTest; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 15 | 16 | @JsonTest 17 | class EntityTest { 18 | 19 | @Autowired 20 | private ObjectMapper objectMapper; 21 | 22 | @ParameterizedTest 23 | @ValueSource(classes = {Typo.class, Workspace.class, Account.class}) 24 | void equalsHashCodeVerifier(Class> clazz) throws Exception { 25 | final var entityOne = clazz.getConstructor().newInstance(); 26 | final var entityTwo = clazz.getConstructor().newInstance(); 27 | 28 | assertThat(entityOne).isEqualTo(entityOne) 29 | .hasSameHashCodeAs(entityOne) 30 | .isNotEqualTo(new Object()) 31 | .isNotEqualTo(null) 32 | .isNotEqualTo(entityTwo) 33 | .hasSameHashCodeAs(entityTwo); 34 | } 35 | 36 | @ParameterizedTest 37 | @ValueSource(classes = {Typo.class, Workspace.class, Account.class}) 38 | void isSerializeNewEntityToJson(Class clazz) { 39 | assertDoesNotThrow(() -> objectMapper.writeValueAsString(clazz.getConstructor().newInstance())); 40 | } 41 | 42 | @ParameterizedTest 43 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getEntities") 44 | void isSerializeEntityToJson(final Identifiable entity) { 45 | assertDoesNotThrow(() -> objectMapper.writeValueAsString(entity)); 46 | } 47 | 48 | @ParameterizedTest 49 | @ValueSource(classes = {Typo.class, Workspace.class, Account.class}) 50 | void isNotExceptionForToStringWithNewEntity(Class clazz) throws Exception { 51 | assertDoesNotThrow(clazz.getConstructor().newInstance()::toString); 52 | } 53 | 54 | @ParameterizedTest 55 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getEntities") 56 | void isNotExceptionForToStringWithEntity(final Identifiable entity) { 57 | assertDoesNotThrow(entity::toString); 58 | } 59 | 60 | @ParameterizedTest 61 | @ValueSource(classes = {Typo.class, Workspace.class, Account.class}) 62 | void isEqualsIfIdsEquals(Class> clazz) throws Exception { 63 | final var entityOne = clazz.getConstructor().newInstance().setId(1L); 64 | final var entityTwo = clazz.getConstructor().newInstance().setId(1L); 65 | assertThat(entityOne).isEqualTo(entityTwo).hasSameHashCodeAs(entityTwo); 66 | } 67 | 68 | @ParameterizedTest 69 | @ValueSource(classes = {Typo.class, Workspace.class, Account.class}) 70 | void isNotEqualsIfIdsNotEquals(Class> clazz) throws Exception { 71 | final var entityOne = clazz.getConstructor().newInstance().setId(1L); 72 | final var entityTwo = clazz.getConstructor().newInstance().setId(2L); 73 | assertThat(entityOne).isNotEqualTo(entityTwo).hasSameHashCodeAs(entityTwo); 74 | } 75 | 76 | @ParameterizedTest 77 | @ValueSource(classes = {Typo.class, Workspace.class, Account.class}) 78 | void isNotEqualsIfOneIdNull(Class> clazz) throws Exception { 79 | final var entityOne = clazz.getConstructor().newInstance().setId(1L); 80 | final var entityTwo = clazz.getConstructor().newInstance(); 81 | assertThat(entityOne).isNotEqualTo(entityTwo).hasSameHashCodeAs(entityTwo); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/test/java/io/hexlet/typoreporter/repository/WorkspaceRoleRepositoryIT.java: -------------------------------------------------------------------------------- 1 | package io.hexlet.typoreporter.repository; 2 | 3 | import com.github.database.rider.core.api.configuration.DBUnit; 4 | import com.github.database.rider.core.api.dataset.DataSet; 5 | import com.github.database.rider.spring.api.DBRider; 6 | import io.hexlet.typoreporter.config.audit.AuditConfiguration; 7 | import io.hexlet.typoreporter.domain.workspace.WorkspaceRole; 8 | import io.hexlet.typoreporter.test.DBUnitEnumPostgres; 9 | import org.junit.jupiter.params.ParameterizedTest; 10 | import org.junit.jupiter.params.provider.MethodSource; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; 13 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 14 | import org.springframework.context.annotation.Import; 15 | import org.springframework.test.context.DynamicPropertyRegistry; 16 | import org.springframework.test.context.DynamicPropertySource; 17 | import org.springframework.transaction.annotation.Transactional; 18 | import org.testcontainers.containers.PostgreSQLContainer; 19 | import org.testcontainers.junit.jupiter.Container; 20 | import org.testcontainers.junit.jupiter.Testcontainers; 21 | 22 | import java.util.List; 23 | 24 | import static com.github.database.rider.core.api.configuration.Orthography.LOWERCASE; 25 | import static io.hexlet.typoreporter.test.Constraints.POSTGRES_IMAGE; 26 | import static org.assertj.core.api.Assertions.assertThat; 27 | 28 | @DataJpaTest 29 | @Testcontainers 30 | @Import(AuditConfiguration.class) 31 | @Transactional 32 | @DBRider 33 | @DBUnit(caseInsensitiveStrategy = LOWERCASE, dataTypeFactoryClass = DBUnitEnumPostgres.class, cacheConnection = false) 34 | @DataSet(value = {"accounts.yml", "workspaces.yml", "workspaceRoles.yml"}) 35 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 36 | public class WorkspaceRoleRepositoryIT { 37 | @Container 38 | public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(POSTGRES_IMAGE) 39 | .withPassword("inmemory") 40 | .withUsername("inmemory"); 41 | 42 | @DynamicPropertySource 43 | static void datasourceProperties(DynamicPropertyRegistry registry) { 44 | registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); 45 | registry.add("spring.datasource.password", postgreSQLContainer::getPassword); 46 | registry.add("spring.datasource.username", postgreSQLContainer::getUsername); 47 | } 48 | 49 | @Autowired 50 | private WorkspaceRoleRepository workspaceRoleRepository; 51 | @ParameterizedTest 52 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getAccountsIdExist") 53 | void getWorkspaceRolesByAccountIdIsSuccessful(final Long accountId) { 54 | List workspaceRoles = workspaceRoleRepository.getWorkspaceRolesByAccountId(accountId); 55 | 56 | assertThat(workspaceRoles).isNotEmpty(); 57 | assertThat(workspaceRoles.get(0).getAccount().getId()).isEqualTo(accountId); 58 | } 59 | 60 | @ParameterizedTest 61 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getAccountEmailExist") 62 | void getWorkspaceRolesByAccountIdIsSuccessful(final String email) { 63 | List workspaceRoles = workspaceRoleRepository.getWorkspaceRolesByAccountEmail(email); 64 | 65 | assertThat(workspaceRoles).isNotEmpty(); 66 | assertThat(workspaceRoles.get(0).getAccount().getEmail()).isEqualTo(email); 67 | } 68 | 69 | @ParameterizedTest 70 | @MethodSource("io.hexlet.typoreporter.test.factory.EntitiesFactory#getWorkspacesIdExist") 71 | void getWorkspaceRolesByWorkspaceIdIsSuccessful(final Long workspaceId) { 72 | List workspaceRoles = workspaceRoleRepository.getWorkspaceRolesByWorkspaceId(workspaceId); 73 | 74 | assertThat(workspaceRoles).isNotEmpty(); 75 | assertThat(workspaceRoles.get(0).getWorkspace().getId()).isEqualTo(workspaceId); 76 | } 77 | } 78 | --------------------------------------------------------------------------------