diff --git a/Api/Api.csproj b/Api/Api.csproj index 5e9c8ac..f4dbc31 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -17,6 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Api/Controllers/v1/FeedbacksController.cs b/Api/Controllers/v1/FeedbacksController.cs new file mode 100644 index 0000000..b6edaf9 --- /dev/null +++ b/Api/Controllers/v1/FeedbacksController.cs @@ -0,0 +1,31 @@ +using Application.DataTransferObjects.Feedback; +using Application.Interfaces; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Controllers.v1 +{ + [Route("api/v{version:apiVersion}/[controller]")] + [ApiController] + [ApiVersion("1.0")] + [Authorize] + public class FeedbacksController(ILogger logger, IGitHubService gitHubService) : ControllerBase + { + [HttpPost] + public async Task> PostFeedback([FromForm] FeedbackDto feedbackDto) + { + try + { + var issue = await gitHubService.CreateIssueAsync(feedbackDto); + return Ok(new {url = issue.HtmlUrl}); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + } +} diff --git a/Api/Program.cs b/Api/Program.cs index 6b1cf52..30b1f99 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,5 +1,3 @@ -// Configure Serilog logger - using Api.Configurations; using Api.Middleware; using Application; @@ -43,6 +41,11 @@ try .ValidateDataAnnotations() .ValidateOnStart(); + builder.Services.AddOptions() + .BindConfiguration("GitHubSettings") + .ValidateDataAnnotations() + .ValidateOnStart(); + var jwtSection = builder.Configuration.GetRequiredSection("Jwt"); builder.Services.ConfigureAndAddAuthentication(jwtSection); diff --git a/Application/Application.csproj b/Application/Application.csproj index 1292af0..e71c31a 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -10,6 +10,7 @@ + diff --git a/Application/ApplicationDependencyInjection.cs b/Application/ApplicationDependencyInjection.cs index 4258057..f146898 100644 --- a/Application/ApplicationDependencyInjection.cs +++ b/Application/ApplicationDependencyInjection.cs @@ -41,6 +41,7 @@ public static class ApplicationDependencyInjection services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services; } diff --git a/Application/DataTransferObjects/Feedback/FeedbackDto.cs b/Application/DataTransferObjects/Feedback/FeedbackDto.cs new file mode 100644 index 0000000..0a741ca --- /dev/null +++ b/Application/DataTransferObjects/Feedback/FeedbackDto.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace Application.DataTransferObjects.Feedback; + +public class FeedbackDto +{ + public required string Type { get; set; } + public required string Title { get; set; } + public required string Description { get; set; } + public string? ExpectedBehavior { get; set; } + public string? ActualBehavior { get; set; } + public string? Reproduction { get; set; } + public string? Severity { get; set; } + public string? Os { get; set; } + public required string AppVersion { get; set; } + public string? Email { get; set; } + public IFormFile? Screenshot { get; set; } +} \ No newline at end of file diff --git a/Application/Interfaces/IGitHubService.cs b/Application/Interfaces/IGitHubService.cs new file mode 100644 index 0000000..c5d0b57 --- /dev/null +++ b/Application/Interfaces/IGitHubService.cs @@ -0,0 +1,9 @@ +using Application.DataTransferObjects.Feedback; +using Octokit; + +namespace Application.Interfaces; + +public interface IGitHubService +{ + public Task CreateIssueAsync(FeedbackDto feedbackDto); +} \ No newline at end of file diff --git a/Application/Repositories/PlayerRepository.cs b/Application/Repositories/PlayerRepository.cs index 03e1c93..30c0346 100644 --- a/Application/Repositories/PlayerRepository.cs +++ b/Application/Repositories/PlayerRepository.cs @@ -151,7 +151,8 @@ public class PlayerRepository(ApplicationContext context, IMapper mapper, ILogge ModifiedBy = null, DismissalReason = null, DismissedAt = null, - IsDismissed = false + IsDismissed = false, + Id = Guid.CreateVersion7() }; await context.Players.AddAsync(newPlayer, cancellationToken); diff --git a/Application/Services/GitHubService.cs b/Application/Services/GitHubService.cs new file mode 100644 index 0000000..8f32867 --- /dev/null +++ b/Application/Services/GitHubService.cs @@ -0,0 +1,70 @@ +using Application.DataTransferObjects.Feedback; +using Application.Interfaces; +using Microsoft.Extensions.Options; +using Octokit; +using Utilities.Classes; +using ProductHeaderValue = Octokit.ProductHeaderValue; + +namespace Application.Services; + +public class GitHubService(IOptions gitHubSettingOption) : IGitHubService +{ + private readonly GitHubSetting _gitHubSetting = gitHubSettingOption.Value; + + public async Task CreateIssueAsync(FeedbackDto feedbackDto) + { + var gitHubClient = new GitHubClient(new ProductHeaderValue("PlayerManagerApi")) + { + Credentials = new Credentials(_gitHubSetting.Token) + }; + + var label = feedbackDto.Type == "bug" ? "bug" : "enhancement"; + var issueBody = $"**Description:**\n{feedbackDto.Description}\n\n"; + + if (label == "bug") + { + issueBody += $"**Expected Behavior:**\n{feedbackDto.ExpectedBehavior}\n\n" + + $"**Actual Behavior:**\n{feedbackDto.ActualBehavior}\n\n" + + $"**Steps to Reproduce:**\n{feedbackDto.Reproduction}\n\n" + + $"**Severity:** {feedbackDto.Severity}\n\n" + + $"**Operating System:** {feedbackDto.Os}\n\n" + + $"**App Version:** {feedbackDto.AppVersion}\n\n"; + } + + if (feedbackDto.Email is not null) + { + issueBody += $"**Contact:** {feedbackDto.Email}\n\n"; + } + + if (feedbackDto.Screenshot is { Length: > 0 }) + { + using var stream = new MemoryStream(); + await feedbackDto.Screenshot.CopyToAsync(stream); + var fileBytes = stream.ToArray(); + + var filePath = $"screenshots/{Guid.NewGuid()}.png"; + var createFileRequest = new CreateFileRequest( + "Upload Screenshot", + Convert.ToBase64String(fileBytes), + false + ); + + var fileResponse = await gitHubClient.Repository.Content.CreateFile( + _gitHubSetting.Owner, + _gitHubSetting.Name, + filePath, + createFileRequest + ); + + issueBody += $"**Screenshot:**\n\n ![Screenshot]({fileResponse.Content.DownloadUrl})"; + } + + var createIssue = new NewIssue($"[{feedbackDto.Type.ToUpper()}] {feedbackDto.Title}") + { + Body = issueBody + }; + createIssue.Labels.Add(label); + + return await gitHubClient.Issue.Create(_gitHubSetting.Owner, _gitHubSetting.Name, createIssue); + } +} \ No newline at end of file diff --git a/README.md b/README.md index 436a36f..ac31611 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,15 @@ All notable changes to this project are documented here. This project is currently in the **Beta Phase**. --- +### **[0.8.0]** - *2025-03-11* +#### ✨ Added +- **Feedback Page:** Users can now submit feedback, including bug reports and feature requests. +- **GitHub Integration:** Feedback is automatically created as a GitHub issue, providing a direct link to track progress. +- **Screenshot Upload:** Users can attach a screenshot to better illustrate issues. +- **Success Message:** After submission, the form hides, and a success message with the GitHub issue link is displayed. + +🛠️ **Fixed** +- (N/A) ### **[0.7.0]** - *2025-02-06* #### ✨ Added diff --git a/Ui/src/app/app-routing.module.ts b/Ui/src/app/app-routing.module.ts index 7f9f73c..ff60b27 100644 --- a/Ui/src/app/app-routing.module.ts +++ b/Ui/src/app/app-routing.module.ts @@ -24,6 +24,7 @@ import {ZombieSiegeDetailComponent} from "./pages/zombie-siege/zombie-siege-deta import {CustomEventDetailComponent} from "./pages/custom-event/custom-event-detail/custom-event-detail.component"; import {DismissPlayerComponent} from "./pages/dismiss-player/dismiss-player.component"; import {MvpComponent} from "./pages/mvp/mvp.component"; +import {FeedbackComponent} from "./pages/feedback/feedback.component"; const routes: Routes = [ {path: 'players', component: PlayerComponent, canActivate: [authGuard]}, @@ -40,6 +41,7 @@ const routes: Routes = [ { path: 'alliance', component: AllianceComponent, canActivate: [authGuard]}, {path: 'account', component: AccountComponent, canActivate: [authGuard]}, {path: 'change-password', component: ChangePasswordComponent, canActivate: [authGuard]}, + {path: 'feedback', component: FeedbackComponent, canActivate: [authGuard]}, {path: 'custom-event', component: CustomEventComponent, canActivate: [authGuard]}, {path: 'custom-event-detail/:id', component: CustomEventDetailComponent, canActivate: [authGuard]}, {path: 'zombie-siege', component: ZombieSiegeComponent, canActivate: [authGuard]}, diff --git a/Ui/src/app/app.module.ts b/Ui/src/app/app.module.ts index 924ece0..6fcbfd5 100644 --- a/Ui/src/app/app.module.ts +++ b/Ui/src/app/app.module.ts @@ -59,6 +59,7 @@ import {PlayerInfoVsDuelComponent} from "./pages/player-information/player-info- import { MvpComponent } from './pages/mvp/mvp.component'; import { AllianceApiKeyComponent } from './pages/alliance/alliance-api-key/alliance-api-key.component'; import { AllianceUserAdministrationComponent } from './pages/alliance/alliance-user-administration/alliance-user-administration.component'; +import { FeedbackComponent } from './pages/feedback/feedback.component'; @NgModule({ declarations: [ @@ -107,7 +108,8 @@ import { AllianceUserAdministrationComponent } from './pages/alliance/alliance-u PlayerExcelImportModalComponent, MvpComponent, AllianceApiKeyComponent, - AllianceUserAdministrationComponent + AllianceUserAdministrationComponent, + FeedbackComponent ], imports: [ BrowserModule, diff --git a/Ui/src/app/navigation/navigation.component.html b/Ui/src/app/navigation/navigation.component.html index 320204f..0a482c3 100644 --- a/Ui/src/app/navigation/navigation.component.html +++ b/Ui/src/app/navigation/navigation.component.html @@ -78,6 +78,7 @@
+
diff --git a/Ui/src/app/pages/feedback/feedback.component.css b/Ui/src/app/pages/feedback/feedback.component.css new file mode 100644 index 0000000..e69de29 diff --git a/Ui/src/app/pages/feedback/feedback.component.html b/Ui/src/app/pages/feedback/feedback.component.html new file mode 100644 index 0000000..798df48 --- /dev/null +++ b/Ui/src/app/pages/feedback/feedback.component.html @@ -0,0 +1,164 @@ +
+

Submit Feedback

+ + @if (!isSubmitted) { + +
+
+ + +
+ +
+ + + @if (f['title'].invalid && (f['title'].touched || f['title'].dirty)) { +
+ @if (f['title'].hasError('required')) { +

Title is required

+ } +
+ } +
+ +
+ + + @if (f['description'].invalid && (f['description'].touched || f['description'].dirty)) { +
+ @if (f['description'].hasError('required')) { +

Description is required

+ } +
+ } +
+ +
+ + +
+ + @if (feedbackType === 'bug') { +
+ + + @if (f['expectedBehavior'].invalid && (f['expectedBehavior'].touched || f['expectedBehavior'].dirty)) { +
+ @if (f['expectedBehavior'].hasError('required')) { +

ExpectedBehavior is required

+ } +
+ } +
+ +
+ + + @if (f['actualBehavior'].invalid && (f['actualBehavior'].touched || f['actualBehavior'].dirty)) { +
+ @if (f['actualBehavior'].hasError('required')) { +

ActualBehavior is required

+ } +
+ } +
+ +
+ + + @if (f['reproduction'].invalid && (f['reproduction'].touched || f['reproduction'].dirty)) { +
+ @if (f['reproduction'].hasError('required')) { +

Reproduction is required

+ } +
+ } +
+ +
+ + +
+ +
+ + + @if (f['os'].invalid && (f['os'].touched || f['os'].dirty)) { +
+ @if (f['os'].hasError('required')) { +

Operating System is required

+ } +
+ } +
+ +
+ + +
+ +
+ + +
+ + } + +
+ + +
+
+ } @else { +
+

Thank you for your feedback!

+

Your feedback has been successfully submitted.

+

+ You can track the progress of your feedback
+ View Issue on GitHub +

+
+ } + + +
diff --git a/Ui/src/app/pages/feedback/feedback.component.ts b/Ui/src/app/pages/feedback/feedback.component.ts new file mode 100644 index 0000000..f3a172f --- /dev/null +++ b/Ui/src/app/pages/feedback/feedback.component.ts @@ -0,0 +1,101 @@ +import {Component, inject, OnInit} from '@angular/core'; +import {FormBuilder, FormGroup, Validators} from "@angular/forms"; +import {environment} from "../../../environments/environment"; +import {FeedbackService} from "../../services/feedback.service"; +import {ToastrService} from "ngx-toastr"; +import {Router} from "@angular/router"; + +@Component({ + selector: 'app-feedback', + templateUrl: './feedback.component.html', + styleUrl: './feedback.component.css' +}) +export class FeedbackComponent implements OnInit { + + private readonly _fb: FormBuilder = inject(FormBuilder); + private readonly _feedbackService: FeedbackService = inject(FeedbackService); + private readonly _toastr: ToastrService = inject(ToastrService); + private readonly _router: Router = inject(Router); + + public feedbackForm!: FormGroup; + public feedbackType: string = 'feature'; + public isSubmitted: boolean = false; + public issueUrl: string = ''; + + private appVersion: string = environment.version; + + get f() { + return this.feedbackForm.controls; + } + + ngOnInit() { + this.createForm(); + } + + createForm() { + this.feedbackForm = this._fb.group({ + type: [this.feedbackType, Validators.required], + title: ['', Validators.required], + description: ['', Validators.required], + appVersion: [this.appVersion], + email: ['', Validators.email], + screenshot: [null] + }); + } + + onTypeChange(event: any) { + const type = event.target.value; + this.feedbackType = type; + if (type === 'bug') { + this.feedbackForm.addControl('expectedBehavior', this._fb.control('', Validators.required)); + this.feedbackForm.addControl('actualBehavior', this._fb.control('', Validators.required)); + this.feedbackForm.addControl('reproduction', this._fb.control('', Validators.required)); + this.feedbackForm.addControl('severity', this._fb.control('medium', Validators.required)); + this.feedbackForm.addControl('os', this._fb.control('', Validators.required)); + } else { + this.feedbackForm.removeControl('expectedBehavior'); + this.feedbackForm.removeControl('actualBehavior'); + this.feedbackForm.removeControl('reproduction'); + this.feedbackForm.removeControl('severity'); + this.feedbackForm.removeControl('os'); + } + } + + onSubmit() { + if (this.feedbackForm.valid) { + const formData = new FormData(); + Object.keys(this.feedbackForm.value).forEach((key) => { + formData.append(key, this.feedbackForm.value[key]); + }); + + if (this.feedbackForm.get('screenshot')?.value) { + formData.append('screenshot', this.feedbackForm.get('screenshot')?.value); + } + + this._feedbackService.submitFeedback(formData).subscribe({ + next: ((response) => { + if (response) { + this.isSubmitted = true; + this.issueUrl = response.url; + } else { + this._toastr.error('Failure submitted feedback', 'Feedback'); + } + }), + error: (error) => { + console.log(error); + this._toastr.error('Failure submitted feedback', 'Feedback'); + } + }); + } + } + + onFileChange(event: any) { + if (event.target.files && event.target.files.length > 0) { + this.feedbackForm.patchValue({ screenshot: event.target.files[0] }); + } + } + + onCancel() { + this._router.navigate(['/']).then(); + } +} diff --git a/Ui/src/app/services/feedback.service.ts b/Ui/src/app/services/feedback.service.ts new file mode 100644 index 0000000..4f14be3 --- /dev/null +++ b/Ui/src/app/services/feedback.service.ts @@ -0,0 +1,18 @@ +import {inject, Injectable} from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {Observable} from "rxjs"; + +@Injectable({ + providedIn: 'root' +}) +export class FeedbackService { + + private readonly _serviceUrl = environment.apiBaseUrl + 'Feedbacks/'; + private readonly _httpClient: HttpClient = inject(HttpClient); + + public submitFeedback(formData: FormData): Observable<{url:string}> { + return this._httpClient.post<{url:string}>(this._serviceUrl, formData); + } + +} diff --git a/Utilities/Classes/GitHubSetting.cs b/Utilities/Classes/GitHubSetting.cs new file mode 100644 index 0000000..7050084 --- /dev/null +++ b/Utilities/Classes/GitHubSetting.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Utilities.Classes; + +public class GitHubSetting +{ + [Required] + public required string Owner { get; set; } + + [Required] + public required string Name { get; set; } + + [Required] + public required string Token { get; set; } +} \ No newline at end of file diff --git a/Utilities/Utilities.csproj b/Utilities/Utilities.csproj index 631100d..c5e00f0 100644 --- a/Utilities/Utilities.csproj +++ b/Utilities/Utilities.csproj @@ -8,7 +8,6 @@ - @@ -16,6 +15,7 @@ +