diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..8851546 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +stages: + - .pre + - build + - test + - deploy + +variables: + GIT_SUBMODULE_STRATEGY: recursive + PUBLISH_DIR: BackendPublish + +top-level-job: + stage: .pre + image: alpine:latest + script: + - echo "Starting pipeline" + +include: + - local: '/SurveyBackend/backend.gitlab-ci.yml' + rules: + - changes: + - 'SurveyBackend/*' + - local: '/SurveyFrontend/frontend.gitlab-ci.yml' + rules: + - changes: + - 'SurveyFrontend/*' diff --git a/SurveyBackend/SurveyBackend.API/Contexts/UserContext.cs b/SurveyBackend/SurveyBackend.API/Contexts/UserContext.cs new file mode 100644 index 0000000..298cc59 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Contexts/UserContext.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; +using SurveyBackend.Core.Contexts; + +namespace SurveyBackend.Contexts; + +public class UserContext : IUserContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public UserContext(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public int UserId => + int.Parse( + _httpContextAccessor.HttpContext?.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier) + ?.Value ?? throw new UnauthorizedAccessException()); +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Controllers/AuthController.cs b/SurveyBackend/SurveyBackend.API/Controllers/AuthController.cs new file mode 100644 index 0000000..6e5148a --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Controllers/AuthController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SurveyBackend.DTOs; +using SurveyBackend.Mappers; +using IAuthorizationService = SurveyBackend.Core.Services.IAuthorizationService; + +namespace SurveyBackend.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + public AuthController(IAuthorizationService authorizationService) + { + _authorizationService = authorizationService; + } + + [AllowAnonymous] + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task LogIn([FromBody] UserLoginDto loginData) + { + var token = await _authorizationService.LogInUser(loginData.Email, loginData.Password); + return Ok(new { token = token }); + } + + [AllowAnonymous] + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Register([FromBody] UserRegistrationDto registerData) + { + var token = await _authorizationService.RegisterUser( + AuthMapper.UserRegistrationToModel(registerData)); + return Ok(new { token = token }); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Controllers/QuestionController.cs b/SurveyBackend/SurveyBackend.API/Controllers/QuestionController.cs new file mode 100644 index 0000000..a8b5764 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Controllers/QuestionController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SurveyBackend.Core.Contexts; +using SurveyBackend.DTOs.Question; +using SurveyBackend.Mappers; +using SurveyLib.Core.Services; + +namespace SurveyBackend.Controllers; + +[ApiController] +[Route("api/surveys/{surveyId}/questions")] +public class QuestionController : ControllerBase +{ + private readonly IQuestionService _questionService; + + public QuestionController(IQuestionService questionService, IUserContext userContext) + { + _questionService = questionService; + } + + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task GetBySurveyId([FromRoute] int surveyId) + { + var questions = await _questionService.GetQuestionsBySurveyIdAsync(surveyId); + var result = questions.Select(QuestionMapper.ModelToQuestionDto).ToList(); + return Ok(result); + } + + [Authorize] + [HttpPost] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task AddQuestion(CreateQuestionDto dto, [FromRoute] int surveyId) + { + var model = QuestionMapper.QuestionCreationToModel(dto, surveyId); + await _questionService.AddQuestionAsync(model); + return Created(); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Controllers/SurveyController.cs b/SurveyBackend/SurveyBackend.API/Controllers/SurveyController.cs new file mode 100644 index 0000000..d471152 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Controllers/SurveyController.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SurveyBackend.Core.Contexts; +using SurveyBackend.DTOs.Survey; +using SurveyBackend.Mappers; +using SurveyBackend.Services.Exceptions; +using SurveyLib.Core.Models; +using SurveyLib.Core.Services; + +namespace SurveyBackend.Controllers; + +[ApiController] +[Route("api/surveys")] +public class SurveyController : ControllerBase +{ + private readonly ISurveyService _surveyService; + private readonly IUserContext _userContext; + + public SurveyController(ISurveyService surveyService, IUserContext userContext) + { + _surveyService = surveyService; + _userContext = userContext; + } + + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task Get() + { + var surveys = await _surveyService.GetSurveysAsync(); + var result = surveys.Select(SurveyMapper.ModelToOutputDto); + return Ok(result); + } + + [AllowAnonymous] + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(OutputSurveyDto), StatusCodes.Status200OK)] + public async Task Get(int id) + { + var survey = await _surveyService.GetSurveyAsync(id); + var result = SurveyMapper.ModelToOutputDto(survey); + return Ok(result); + } + + [Authorize] + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task Post([FromBody] CreateSurveyDto dto) + { + var userId = _userContext.UserId; + + var survey = SurveyMapper.CreateDtoToModel(dto, userId); + await _surveyService.AddSurveyAsync(survey); + return Created(); + } + + [Authorize] + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Delete(int id) + { + await _surveyService.DeleteSurveyAsync(id); + return Ok(); + } + + [Authorize] + [HttpGet("my")] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task GetMySurveys() + { + var userId = _userContext.UserId; + + var result = await _surveyService.GetSurveysByUserIdAsync(userId); + return Ok(result); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Controllers/TestController.cs b/SurveyBackend/SurveyBackend.API/Controllers/TestController.cs new file mode 100644 index 0000000..cbf5688 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Controllers/TestController.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; +using SurveyLib.Core.Services; + +namespace SurveyBackend.Controllers; + +[ApiController] +[Route("api/test")] +public class TestController : ControllerBase +{ +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/Question/CreateQuestionDTO.cs b/SurveyBackend/SurveyBackend.API/DTOs/Question/CreateQuestionDTO.cs new file mode 100644 index 0000000..2379b73 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/Question/CreateQuestionDTO.cs @@ -0,0 +1,9 @@ +namespace SurveyBackend.DTOs.Question; + +public class CreateQuestionDto +{ + public required string Title { get; set; } + public required string QuestionType { get; set; } + + public List? AnswerVariants { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/Question/OutputAnswerVariantDTO.cs b/SurveyBackend/SurveyBackend.API/DTOs/Question/OutputAnswerVariantDTO.cs new file mode 100644 index 0000000..9112795 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/Question/OutputAnswerVariantDTO.cs @@ -0,0 +1,8 @@ +namespace SurveyBackend.DTOs.Question; + +public class OutputAnswerVariantDto +{ + public required int Id { get; set; } + public required int QuestionId { get; set; } + public required string Text { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/Question/OutputQuestionDto.cs b/SurveyBackend/SurveyBackend.API/DTOs/Question/OutputQuestionDto.cs new file mode 100644 index 0000000..bfae54c --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/Question/OutputQuestionDto.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.DTOs.Question; + +public class OutputQuestionDto +{ + public required int Id { get; set; } + public required int SurveyId { get; set; } + public required string Title { get; set; } + public required string QuestionType { get; set; } + public List? AnswerVariants { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/Survey/CreateSurveyDTO.cs b/SurveyBackend/SurveyBackend.API/DTOs/Survey/CreateSurveyDTO.cs new file mode 100644 index 0000000..67bfaf6 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/Survey/CreateSurveyDTO.cs @@ -0,0 +1,7 @@ +namespace SurveyBackend.DTOs.Survey; + +public class CreateSurveyDto +{ + public required string Title { get; set; } + public string Description { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/Survey/OutputSurveyDto.cs b/SurveyBackend/SurveyBackend.API/DTOs/Survey/OutputSurveyDto.cs new file mode 100644 index 0000000..faec104 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/Survey/OutputSurveyDto.cs @@ -0,0 +1,9 @@ +namespace SurveyBackend.DTOs.Survey; + +public class OutputSurveyDto +{ + public required int Id { get; set; } + public required string Title { get; set; } + public required string Description { get; set; } + public int? CreatedBy { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/UserLoginDto.cs b/SurveyBackend/SurveyBackend.API/DTOs/UserLoginDto.cs new file mode 100644 index 0000000..8e58ee2 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/UserLoginDto.cs @@ -0,0 +1,7 @@ +namespace SurveyBackend.DTOs; + +public record UserLoginDto +{ + public required string Email { get; set; } + public required string Password { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/DTOs/UserRegistrationDto.cs b/SurveyBackend/SurveyBackend.API/DTOs/UserRegistrationDto.cs new file mode 100644 index 0000000..3c0808d --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/DTOs/UserRegistrationDto.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.DTOs; + +public record UserRegistrationDto +{ + public string Email { get; set; } + public string Username { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Mappers/AuthMapper.cs b/SurveyBackend/SurveyBackend.API/Mappers/AuthMapper.cs new file mode 100644 index 0000000..50b1980 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Mappers/AuthMapper.cs @@ -0,0 +1,15 @@ +using SurveyBackend.Core.Models; +using SurveyBackend.DTOs; + +namespace SurveyBackend.Mappers; + +public static class AuthMapper +{ + public static User UserRegistrationToModel(UserRegistrationDto dto) => new User + { + Email = dto.Email, + FirstName = dto.FirstName, + LastName = dto.LastName, + Password = dto.Password, + }; +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Mappers/QuestionMapper.cs b/SurveyBackend/SurveyBackend.API/Mappers/QuestionMapper.cs new file mode 100644 index 0000000..b6fd399 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Mappers/QuestionMapper.cs @@ -0,0 +1,57 @@ +using SurveyBackend.DTOs.Question; +using SurveyBackend.Services.Exceptions; +using SurveyLib.Core.Models; +using SurveyLib.Core.Models.QuestionVariants; + +namespace SurveyBackend.Mappers; + +public static class QuestionMapper +{ + public static QuestionBase QuestionCreationToModel(CreateQuestionDto dto, int surveyId) + { + return dto.QuestionType.ToLower() switch + { + "textquestion" => new TextQuestion + { + Title = dto.Title, + SurveyId = surveyId, + }, + "singleanswerquestion" => new SingleAnswerQuestion + { + Title = dto.Title, + SurveyId = surveyId, + AnswerVariants = dto.AnswerVariants?.Select(v => new AnswerVariant { Text = v }).ToList() ?? [], + }, + "multipleanswerquestion" => new MultipleAnswerQuestion + { + Title = dto.Title, + SurveyId = surveyId, + AnswerVariants = dto.AnswerVariants?.Select(v => new AnswerVariant { Text = v }).ToList() ?? [] + }, + _ => throw new BadRequestException("Unknown question type") + }; + } + + public static OutputQuestionDto ModelToQuestionDto(QuestionBase question) + { + var withAnswerVariants = question.GetType() != typeof(TextQuestion); + return new OutputQuestionDto + { + Id = question.Id, + SurveyId = question.SurveyId, + Title = question.Title, + QuestionType = question.Discriminator, + AnswerVariants = withAnswerVariants ? AnswerVariantsToDto(question.AnswerVariants) : null, + }; + } + + private static List AnswerVariantsToDto(IEnumerable answerVariants) + { + return answerVariants.Select(av => new OutputAnswerVariantDto + { + Id = av.Id, + QuestionId = av.QuestionId, + Text = av.Text, + }).ToList(); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Mappers/SurveyMapper.cs b/SurveyBackend/SurveyBackend.API/Mappers/SurveyMapper.cs new file mode 100644 index 0000000..46f85cb --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Mappers/SurveyMapper.cs @@ -0,0 +1,28 @@ +using SurveyBackend.DTOs.Survey; +using SurveyLib.Core.Models; + +namespace SurveyBackend.Mappers; + +public static class SurveyMapper +{ + public static Survey CreateDtoToModel(CreateSurveyDto dto, int userId) + { + return new Survey + { + Title = dto.Title, + Description = dto.Description, + CreatedBy = userId + }; + } + + public static OutputSurveyDto ModelToOutputDto(Survey survey) + { + return new OutputSurveyDto + { + Id = survey.Id, + Title = survey.Title, + Description = survey.Description, + CreatedBy = survey.CreatedBy, + }; + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Middlewares/ExceptionsMiddleware.cs b/SurveyBackend/SurveyBackend.API/Middlewares/ExceptionsMiddleware.cs new file mode 100644 index 0000000..65c94f8 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Middlewares/ExceptionsMiddleware.cs @@ -0,0 +1,49 @@ +using SurveyBackend.Services.Exceptions; + +namespace SurveyBackend.Middlewares; + +public class ExceptionsMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionsMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (ServiceException ex) + { + context.Response.StatusCode = ex.StatusCode; + context.Response.ContentType = "application/json"; + + var response = new + { + error = ex.Message + }; + + await context.Response.WriteAsJsonAsync(response); + } + catch (Exception ex) + { + _logger.LogError(ex.Message); + + context.Response.StatusCode = 500; + context.Response.ContentType = "application/json"; + + var response = new + { + error = "Internal Server Error. GG WP, request bub fix" + }; + + await context.Response.WriteAsJsonAsync(response); + } + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.API/Program.cs b/SurveyBackend/SurveyBackend.API/Program.cs new file mode 100644 index 0000000..4c1f57b --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/Program.cs @@ -0,0 +1,124 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using SurveyBackend.Contexts; +using SurveyBackend.Core.Contexts; +using SurveyBackend.Core.Repositories; +using SurveyBackend.Core.Services; +using SurveyBackend.Infrastructure.Data; +using SurveyBackend.Infrastructure.Repositories; +using SurveyBackend.Middlewares; +using SurveyBackend.Services; +using SurveyBackend.Services.Services; +using SurveyLib.Core.Repositories; +using SurveyLib.Core.Services; +using SurveyLib.Infrastructure.EFCore.Data; +using SurveyLib.Infrastructure.EFCore.Repositories; + +namespace SurveyBackend; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + AuthOptions.MakeOptions(builder.Configuration, Environment.GetEnvironmentVariable("JWT_SECRET_KEY")); + + builder.Services.AddAuthorization(); + + builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); + + builder.Services.AddScoped(provider => provider.GetRequiredService()); + + builder.Services.AddHttpContextAccessor(); + + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = AuthOptions.Issuer, + ValidAudience = AuthOptions.Audience, + IssuerSigningKey = AuthOptions.SymmetricSecurityKey + }; + }); + + builder.Services.AddControllers().AddJsonOptions(opts => + { + opts.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => + { + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "JWT Authorization header using the Bearer scheme.", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = JwtBearerDefaults.AuthenticationScheme + } + }, + Array.Empty() + } + }); + }); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(options => { options.RouteTemplate = "api/swagger/{documentName}/swagger.json"; }); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/api/swagger/v1/swagger.json", "Survey Backend V1"); + options.RoutePrefix = "api/swagger"; + }); + } + + app.UseMiddleware(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapControllers(); + + app.Run(); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend/Properties/launchSettings.json b/SurveyBackend/SurveyBackend.API/Properties/launchSettings.json similarity index 96% rename from SurveyBackend/SurveyBackend/Properties/launchSettings.json rename to SurveyBackend/SurveyBackend.API/Properties/launchSettings.json index 589b161..6198e4a 100644 --- a/SurveyBackend/SurveyBackend/Properties/launchSettings.json +++ b/SurveyBackend/SurveyBackend.API/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "api/swagger", "applicationUrl": "http://localhost:5231", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/SurveyBackend/SurveyBackend.API/SurveyBackend.API.csproj b/SurveyBackend/SurveyBackend.API/SurveyBackend.API.csproj new file mode 100644 index 0000000..fef66e7 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/SurveyBackend.API.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + SurveyBackend + + + + + Never + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/SurveyBackend/SurveyBackend/SurveyBackend.http b/SurveyBackend/SurveyBackend.API/SurveyBackend.http similarity index 100% rename from SurveyBackend/SurveyBackend/SurveyBackend.http rename to SurveyBackend/SurveyBackend.API/SurveyBackend.http diff --git a/SurveyBackend/SurveyBackend.API/appsettings.Development.json b/SurveyBackend/SurveyBackend.API/appsettings.Development.json new file mode 100644 index 0000000..bcc29ea --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Data Source=Application.db" + }, + "JwtSettings": { + "SecretKey": "sigma_super_secret_key_for_jwt_tokens_yo", + "Issuer": "SurveyBackend", + "Audience": "SurveyClient", + "ExpiresInMinutes": 600 + } +} diff --git a/SurveyBackend/SurveyBackend.API/appsettings.json b/SurveyBackend/SurveyBackend.API/appsettings.json new file mode 100644 index 0000000..3b14cf8 --- /dev/null +++ b/SurveyBackend/SurveyBackend.API/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Data Source=Application.db" + }, + "JwtSettings": { + "SecretKey": "sigma_super_secret_key_for_jwt_tokens_yo_that_should_be_stored_in_ENV", + "Issuer": "SurveyBackend", + "Audience": "SurveyClient", + "ExpiresInMinutes": 600 + } +} diff --git a/SurveyBackend/SurveyBackend.Core/Contexts/IUserContext.cs b/SurveyBackend/SurveyBackend.Core/Contexts/IUserContext.cs new file mode 100644 index 0000000..4cd9127 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Contexts/IUserContext.cs @@ -0,0 +1,6 @@ +namespace SurveyBackend.Core.Contexts; + +public interface IUserContext +{ + int UserId { get; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Models/Group.cs b/SurveyBackend/SurveyBackend.Core/Models/Group.cs new file mode 100644 index 0000000..e74cb3f --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Models/Group.cs @@ -0,0 +1,9 @@ +namespace SurveyBackend.Core.Models; + +public class Group +{ + public int Id { get; set; } + public string Label { get; set; } + + public ICollection Users { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Models/User.cs b/SurveyBackend/SurveyBackend.Core/Models/User.cs new file mode 100644 index 0000000..257d4fa --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Models/User.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; + +namespace SurveyBackend.Core.Models; + +public class User +{ + public int Id { get; set; } + public string Email { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + + public string Password { get; set; } + + public ICollection Groups { get; set; } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Repositories/IGenericRepository.cs b/SurveyBackend/SurveyBackend.Core/Repositories/IGenericRepository.cs new file mode 100644 index 0000000..228d3b1 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Repositories/IGenericRepository.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.Core.Repositories; + +public interface IGenericRepository where T : class +{ + Task GetByIdAsync(int id); + Task> GetAllAsync(); + Task AddAsync(T entity); + Task UpdateAsync(T entity); + Task DeleteAsync(T entity); +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Repositories/IUserRepository.cs b/SurveyBackend/SurveyBackend.Core/Repositories/IUserRepository.cs new file mode 100644 index 0000000..311742b --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Repositories/IUserRepository.cs @@ -0,0 +1,8 @@ +using SurveyBackend.Core.Models; + +namespace SurveyBackend.Core.Repositories; + +public interface IUserRepository : IGenericRepository +{ + public Task GetUserByEmail(string email); +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Services/IAuthorizationService.cs b/SurveyBackend/SurveyBackend.Core/Services/IAuthorizationService.cs new file mode 100644 index 0000000..0237a22 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Services/IAuthorizationService.cs @@ -0,0 +1,9 @@ +using SurveyBackend.Core.Models; + +namespace SurveyBackend.Core.Services; + +public interface IAuthorizationService +{ + public Task LogInUser(string email, string password); + public Task RegisterUser(User user); +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Services/IPasswordHasher.cs b/SurveyBackend/SurveyBackend.Core/Services/IPasswordHasher.cs new file mode 100644 index 0000000..6e2ec29 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Services/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace SurveyBackend.Core.Services; + +public interface IPasswordHasher +{ + public string HashPassword(string password); + public bool Verify(string password, string hashedPassword); +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Core/Services/IUserService.cs b/SurveyBackend/SurveyBackend.Core/Services/IUserService.cs new file mode 100644 index 0000000..cd34c76 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Core/Services/IUserService.cs @@ -0,0 +1,10 @@ +using SurveyBackend.Core.Models; + +namespace SurveyBackend.Core.Services; + +public interface IUserService +{ + public Task GetUserByEmail(string email); + public Task IsEmailTaken(string email); + public Task CreateUserAsync(User user); +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend/SurveyBackend.csproj b/SurveyBackend/SurveyBackend.Core/SurveyBackend.Core.csproj similarity index 52% rename from SurveyBackend/SurveyBackend/SurveyBackend.csproj rename to SurveyBackend/SurveyBackend.Core/SurveyBackend.Core.csproj index 1060e38..8cc2397 100644 --- a/SurveyBackend/SurveyBackend/SurveyBackend.csproj +++ b/SurveyBackend/SurveyBackend.Core/SurveyBackend.Core.csproj @@ -1,14 +1,13 @@ - + net8.0 - enable enable + enable - - + diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/ApplicationDbContext.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..3a0cda7 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/ApplicationDbContext.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using SurveyBackend.Core.Models; +using SurveyLib.Core.Models; +using SurveyLib.Infrastructure.EFCore.Data; + +namespace SurveyBackend.Infrastructure.Data; + +public class ApplicationDbContext : SurveyDbContext +{ + public DbSet Users { get; set; } + public DbSet Groups { get; set; } + + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasOne() + .WithMany() + .HasForeignKey(s => s.CreatedBy) + .OnDelete(DeleteBehavior.SetNull); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418123442_Initial.Designer.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418123442_Initial.Designer.cs new file mode 100644 index 0000000..b8b673d --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418123442_Initial.Designer.cs @@ -0,0 +1,321 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SurveyBackend.Infrastructure.Data; + +#nullable disable + +namespace SurveyBackend.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250418123442_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.15"); + + modelBuilder.Entity("GroupUser", b => + { + b.Property("GroupsId") + .HasColumnType("INTEGER"); + + b.Property("UsersId") + .HasColumnType("INTEGER"); + + b.HasKey("GroupsId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("GroupUser"); + }); + + modelBuilder.Entity("SurveyBackend.Core.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("SurveyBackend.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Answer", b => + { + b.Property("CompletionId") + .HasColumnType("INTEGER"); + + b.Property("QuestionId") + .HasColumnType("INTEGER"); + + b.Property("AnswerText") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CompletionId", "QuestionId"); + + b.HasIndex("QuestionId"); + + b.ToTable("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MultipleAnswerQuestionId") + .HasColumnType("INTEGER"); + + b.Property("QuestionId") + .HasColumnType("INTEGER"); + + b.Property("SingleAnswerQuestionId") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MultipleAnswerQuestionId"); + + b.HasIndex("QuestionId"); + + b.HasIndex("SingleAnswerQuestionId"); + + b.ToTable("AnswerVariant"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("SurveyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SurveyId"); + + b.ToTable("Completions"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("SurveyId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SurveyId"); + + b.ToTable("Questions"); + + b.HasDiscriminator().HasValue("QuestionBase"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Surveys"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("MultipleAnswerQuestion"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("SingleAnswerQuestion"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.TextQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("TextQuestion"); + }); + + modelBuilder.Entity("GroupUser", b => + { + b.HasOne("SurveyBackend.Core.Models.Group", null) + .WithMany() + .HasForeignKey("GroupsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyBackend.Core.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Answer", b => + { + b.HasOne("SurveyLib.Core.Models.Completion", "Completion") + .WithMany("Answers") + .HasForeignKey("CompletionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Completion"); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b => + { + b.HasOne("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", null) + .WithMany("AnswerVariants") + .HasForeignKey("MultipleAnswerQuestionId"); + + b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question") + .WithMany() + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", null) + .WithMany("AnswerVariants") + .HasForeignKey("SingleAnswerQuestionId"); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.HasOne("SurveyLib.Core.Models.Survey", "Survey") + .WithMany("Completions") + .HasForeignKey("SurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Survey"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.HasOne("SurveyLib.Core.Models.Survey", "Survey") + .WithMany("Questions") + .HasForeignKey("SurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Survey"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.Navigation("Completions"); + + b.Navigation("Questions"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", b => + { + b.Navigation("AnswerVariants"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", b => + { + b.Navigation("AnswerVariants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418123442_Initial.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418123442_Initial.cs new file mode 100644 index 0000000..26fbf8b --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418123442_Initial.cs @@ -0,0 +1,243 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SurveyBackend.Infrastructure.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Label = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Surveys", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Surveys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Email = table.Column(type: "TEXT", nullable: false), + FirstName = table.Column(type: "TEXT", nullable: false), + LastName = table.Column(type: "TEXT", nullable: false), + Password = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Completions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SurveyId = table.Column(type: "INTEGER", nullable: false), + FinishedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Completions", x => x.Id); + table.ForeignKey( + name: "FK_Completions_Surveys_SurveyId", + column: x => x.SurveyId, + principalTable: "Surveys", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Questions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SurveyId = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Discriminator = table.Column(type: "TEXT", maxLength: 34, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Questions", x => x.Id); + table.ForeignKey( + name: "FK_Questions_Surveys_SurveyId", + column: x => x.SurveyId, + principalTable: "Surveys", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GroupUser", + columns: table => new + { + GroupsId = table.Column(type: "INTEGER", nullable: false), + UsersId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupUser", x => new { x.GroupsId, x.UsersId }); + table.ForeignKey( + name: "FK_GroupUser_Groups_GroupsId", + column: x => x.GroupsId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupUser_Users_UsersId", + column: x => x.UsersId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Answers", + columns: table => new + { + CompletionId = table.Column(type: "INTEGER", nullable: false), + QuestionId = table.Column(type: "INTEGER", nullable: false), + AnswerText = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Answers", x => new { x.CompletionId, x.QuestionId }); + table.ForeignKey( + name: "FK_Answers_Completions_CompletionId", + column: x => x.CompletionId, + principalTable: "Completions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Answers_Questions_QuestionId", + column: x => x.QuestionId, + principalTable: "Questions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AnswerVariant", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + QuestionId = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(type: "TEXT", nullable: false), + MultipleAnswerQuestionId = table.Column(type: "INTEGER", nullable: true), + SingleAnswerQuestionId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AnswerVariant", x => x.Id); + table.ForeignKey( + name: "FK_AnswerVariant_Questions_MultipleAnswerQuestionId", + column: x => x.MultipleAnswerQuestionId, + principalTable: "Questions", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_AnswerVariant_Questions_QuestionId", + column: x => x.QuestionId, + principalTable: "Questions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AnswerVariant_Questions_SingleAnswerQuestionId", + column: x => x.SingleAnswerQuestionId, + principalTable: "Questions", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Answers_QuestionId", + table: "Answers", + column: "QuestionId"); + + migrationBuilder.CreateIndex( + name: "IX_AnswerVariant_MultipleAnswerQuestionId", + table: "AnswerVariant", + column: "MultipleAnswerQuestionId"); + + migrationBuilder.CreateIndex( + name: "IX_AnswerVariant_QuestionId", + table: "AnswerVariant", + column: "QuestionId"); + + migrationBuilder.CreateIndex( + name: "IX_AnswerVariant_SingleAnswerQuestionId", + table: "AnswerVariant", + column: "SingleAnswerQuestionId"); + + migrationBuilder.CreateIndex( + name: "IX_Completions_SurveyId", + table: "Completions", + column: "SurveyId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupUser_UsersId", + table: "GroupUser", + column: "UsersId"); + + migrationBuilder.CreateIndex( + name: "IX_Questions_SurveyId", + table: "Questions", + column: "SurveyId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Answers"); + + migrationBuilder.DropTable( + name: "AnswerVariant"); + + migrationBuilder.DropTable( + name: "GroupUser"); + + migrationBuilder.DropTable( + name: "Completions"); + + migrationBuilder.DropTable( + name: "Questions"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Surveys"); + } + } +} diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418190738_Add CreatedBy to Survey.Designer.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418190738_Add CreatedBy to Survey.Designer.cs new file mode 100644 index 0000000..e7f3c8e --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418190738_Add CreatedBy to Survey.Designer.cs @@ -0,0 +1,334 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SurveyBackend.Infrastructure.Data; + +#nullable disable + +namespace SurveyBackend.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250418190738_Add CreatedBy to Survey")] + partial class AddCreatedBytoSurvey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.15"); + + modelBuilder.Entity("GroupUser", b => + { + b.Property("GroupsId") + .HasColumnType("INTEGER"); + + b.Property("UsersId") + .HasColumnType("INTEGER"); + + b.HasKey("GroupsId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("GroupUser"); + }); + + modelBuilder.Entity("SurveyBackend.Core.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("SurveyBackend.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Answer", b => + { + b.Property("CompletionId") + .HasColumnType("INTEGER"); + + b.Property("QuestionId") + .HasColumnType("INTEGER"); + + b.Property("AnswerText") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CompletionId", "QuestionId"); + + b.HasIndex("QuestionId"); + + b.ToTable("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MultipleAnswerQuestionId") + .HasColumnType("INTEGER"); + + b.Property("QuestionId") + .HasColumnType("INTEGER"); + + b.Property("SingleAnswerQuestionId") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MultipleAnswerQuestionId"); + + b.HasIndex("QuestionId"); + + b.HasIndex("SingleAnswerQuestionId"); + + b.ToTable("AnswerVariant"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("SurveyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SurveyId"); + + b.ToTable("Completions"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("SurveyId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SurveyId"); + + b.ToTable("Questions"); + + b.HasDiscriminator().HasValue("QuestionBase"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Surveys"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("MultipleAnswerQuestion"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("SingleAnswerQuestion"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.TextQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("TextQuestion"); + }); + + modelBuilder.Entity("GroupUser", b => + { + b.HasOne("SurveyBackend.Core.Models.Group", null) + .WithMany() + .HasForeignKey("GroupsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyBackend.Core.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Answer", b => + { + b.HasOne("SurveyLib.Core.Models.Completion", "Completion") + .WithMany("Answers") + .HasForeignKey("CompletionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Completion"); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b => + { + b.HasOne("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", null) + .WithMany("AnswerVariants") + .HasForeignKey("MultipleAnswerQuestionId"); + + b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question") + .WithMany() + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", null) + .WithMany("AnswerVariants") + .HasForeignKey("SingleAnswerQuestionId"); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.HasOne("SurveyLib.Core.Models.Survey", "Survey") + .WithMany("Completions") + .HasForeignKey("SurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Survey"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.HasOne("SurveyLib.Core.Models.Survey", "Survey") + .WithMany("Questions") + .HasForeignKey("SurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Survey"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.HasOne("SurveyBackend.Core.Models.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.Navigation("Completions"); + + b.Navigation("Questions"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", b => + { + b.Navigation("AnswerVariants"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", b => + { + b.Navigation("AnswerVariants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418190738_Add CreatedBy to Survey.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418190738_Add CreatedBy to Survey.cs new file mode 100644 index 0000000..3debfa9 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250418190738_Add CreatedBy to Survey.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SurveyBackend.Infrastructure.Data.Migrations +{ + /// + public partial class AddCreatedBytoSurvey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedBy", + table: "Surveys", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Surveys_CreatedBy", + table: "Surveys", + column: "CreatedBy"); + + migrationBuilder.AddForeignKey( + name: "FK_Surveys_Users_CreatedBy", + table: "Surveys", + column: "CreatedBy", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Surveys_Users_CreatedBy", + table: "Surveys"); + + migrationBuilder.DropIndex( + name: "IX_Surveys_CreatedBy", + table: "Surveys"); + + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "Surveys"); + } + } +} diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250420143302_Changing questions logic.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250420143302_Changing questions logic.cs new file mode 100644 index 0000000..c4cd463 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/20250420143302_Changing questions logic.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SurveyBackend.Infrastructure.Data.Migrations +{ + /// + public partial class Changingquestionslogic : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AnswerVariant_Questions_MultipleAnswerQuestionId", + table: "AnswerVariant"); + + migrationBuilder.DropForeignKey( + name: "FK_AnswerVariant_Questions_SingleAnswerQuestionId", + table: "AnswerVariant"); + + migrationBuilder.DropIndex( + name: "IX_AnswerVariant_MultipleAnswerQuestionId", + table: "AnswerVariant"); + + migrationBuilder.DropIndex( + name: "IX_AnswerVariant_SingleAnswerQuestionId", + table: "AnswerVariant"); + + migrationBuilder.DropColumn( + name: "MultipleAnswerQuestionId", + table: "AnswerVariant"); + + migrationBuilder.DropColumn( + name: "SingleAnswerQuestionId", + table: "AnswerVariant"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MultipleAnswerQuestionId", + table: "AnswerVariant", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "SingleAnswerQuestionId", + table: "AnswerVariant", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AnswerVariant_MultipleAnswerQuestionId", + table: "AnswerVariant", + column: "MultipleAnswerQuestionId"); + + migrationBuilder.CreateIndex( + name: "IX_AnswerVariant_SingleAnswerQuestionId", + table: "AnswerVariant", + column: "SingleAnswerQuestionId"); + + migrationBuilder.AddForeignKey( + name: "FK_AnswerVariant_Questions_MultipleAnswerQuestionId", + table: "AnswerVariant", + column: "MultipleAnswerQuestionId", + principalTable: "Questions", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_AnswerVariant_Questions_SingleAnswerQuestionId", + table: "AnswerVariant", + column: "SingleAnswerQuestionId", + principalTable: "Questions", + principalColumn: "Id"); + } + } +} diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..05b98b1 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,305 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SurveyBackend.Infrastructure.Data; + +#nullable disable + +namespace SurveyBackend.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.15"); + + modelBuilder.Entity("GroupUser", b => + { + b.Property("GroupsId") + .HasColumnType("INTEGER"); + + b.Property("UsersId") + .HasColumnType("INTEGER"); + + b.HasKey("GroupsId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("GroupUser"); + }); + + modelBuilder.Entity("SurveyBackend.Core.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("SurveyBackend.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Answer", b => + { + b.Property("CompletionId") + .HasColumnType("INTEGER"); + + b.Property("QuestionId") + .HasColumnType("INTEGER"); + + b.Property("AnswerText") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CompletionId", "QuestionId"); + + b.HasIndex("QuestionId"); + + b.ToTable("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("QuestionId") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.ToTable("AnswerVariant"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("SurveyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SurveyId"); + + b.ToTable("Completions"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("SurveyId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SurveyId"); + + b.ToTable("Questions"); + + b.HasDiscriminator().HasValue("QuestionBase"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Surveys"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("MultipleAnswerQuestion"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("SingleAnswerQuestion"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.TextQuestion", b => + { + b.HasBaseType("SurveyLib.Core.Models.QuestionBase"); + + b.HasDiscriminator().HasValue("TextQuestion"); + }); + + modelBuilder.Entity("GroupUser", b => + { + b.HasOne("SurveyBackend.Core.Models.Group", null) + .WithMany() + .HasForeignKey("GroupsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyBackend.Core.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Answer", b => + { + b.HasOne("SurveyLib.Core.Models.Completion", "Completion") + .WithMany("Answers") + .HasForeignKey("CompletionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Completion"); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b => + { + b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question") + .WithMany("AnswerVariants") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.HasOne("SurveyLib.Core.Models.Survey", "Survey") + .WithMany("Completions") + .HasForeignKey("SurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Survey"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.HasOne("SurveyLib.Core.Models.Survey", "Survey") + .WithMany("Questions") + .HasForeignKey("SurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Survey"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.HasOne("SurveyBackend.Core.Models.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Completion", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b => + { + b.Navigation("AnswerVariants"); + + b.Navigation("Answers"); + }); + + modelBuilder.Entity("SurveyLib.Core.Models.Survey", b => + { + b.Navigation("Completions"); + + b.Navigation("Questions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SurveyBackend/SurveyBackend.Infrastructure/Repositories/UserRepository.cs b/SurveyBackend/SurveyBackend.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..86124da --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using SurveyBackend.Core.Models; +using SurveyBackend.Core.Repositories; +using SurveyBackend.Infrastructure.Data; + +namespace SurveyBackend.Infrastructure.Repositories; + +public class UserRepository : IUserRepository +{ + private readonly ApplicationDbContext _context; + + public UserRepository(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id) + { + return await _context.Users.FindAsync(id); + } + + public async Task> GetAllAsync() + { + return await _context.Users.ToListAsync(); + } + + public async Task AddAsync(User entity) + { + await _context.Users.AddAsync(entity); + await _context.SaveChangesAsync(); + } + + public async Task UpdateAsync(User entity) + { + _context.Users.Update(entity); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(User entity) + { + _context.Users.Remove(entity); + await _context.SaveChangesAsync(); + } + + public async Task GetUserByEmail(string email) + { + return await _context.Users.FirstOrDefaultAsync(u => u.Email == email); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Infrastructure/SurveyBackend.Infrastructure.csproj b/SurveyBackend/SurveyBackend.Infrastructure/SurveyBackend.Infrastructure.csproj new file mode 100644 index 0000000..e46ce0e --- /dev/null +++ b/SurveyBackend/SurveyBackend.Infrastructure/SurveyBackend.Infrastructure.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/SurveyBackend/SurveyBackend.Services/AuthOptions.cs b/SurveyBackend/SurveyBackend.Services/AuthOptions.cs new file mode 100644 index 0000000..7d92d6f --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/AuthOptions.cs @@ -0,0 +1,32 @@ +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace SurveyBackend.Services; + +public static class AuthOptions +{ + public static string Issuer; + public static string Audience; + public static TimeSpan TokenLifetime; + private static string? SecurityKey { get; set; } + + public static SymmetricSecurityKey SymmetricSecurityKey + { + get + { + ArgumentNullException.ThrowIfNull(SecurityKey); + + return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecurityKey)); + } + } + + public static void MakeOptions(IConfigurationManager configurationManager, string? securityKey = null) + { + var jwtSettings = configurationManager.GetSection("JwtSettings"); + Issuer = jwtSettings["Issuer"] ?? "DefaultIssuer"; + Audience = jwtSettings["Audience"] ?? "DefaultAudience"; + TokenLifetime = TimeSpan.FromMinutes(int.Parse(jwtSettings["TokenLifetime"] ?? "60")); + SecurityKey = securityKey ?? jwtSettings["SecretKey"]; + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Exceptions/BadRequestException.cs b/SurveyBackend/SurveyBackend.Services/Exceptions/BadRequestException.cs new file mode 100644 index 0000000..b80e69a --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Exceptions/BadRequestException.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.Services.Exceptions; + +public class BadRequestException : ServiceException +{ + public override int StatusCode => 400; + + public BadRequestException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Exceptions/ConflictException.cs b/SurveyBackend/SurveyBackend.Services/Exceptions/ConflictException.cs new file mode 100644 index 0000000..12d14ce --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Exceptions/ConflictException.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.Services.Exceptions; + +public class ConflictException : ServiceException +{ + public override int StatusCode => 409; + + public ConflictException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Exceptions/NotFoundException.cs b/SurveyBackend/SurveyBackend.Services/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..a5e5739 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Exceptions/NotFoundException.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.Services.Exceptions; + +public class NotFoundException : ServiceException +{ + public override int StatusCode => 404; + + public NotFoundException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Exceptions/ServiceException.cs b/SurveyBackend/SurveyBackend.Services/Exceptions/ServiceException.cs new file mode 100644 index 0000000..30fb016 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Exceptions/ServiceException.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.Services.Exceptions; + +public abstract class ServiceException : Exception +{ + public abstract int StatusCode { get; } + + protected ServiceException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Exceptions/UnauthorizedException.cs b/SurveyBackend/SurveyBackend.Services/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000..58ea510 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Exceptions/UnauthorizedException.cs @@ -0,0 +1,10 @@ +namespace SurveyBackend.Services.Exceptions; + +public class UnauthorizedException : ServiceException +{ + public override int StatusCode => 401; + + public UnauthorizedException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Helpers/TokenHelper.cs b/SurveyBackend/SurveyBackend.Services/Helpers/TokenHelper.cs new file mode 100644 index 0000000..7c401a4 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Helpers/TokenHelper.cs @@ -0,0 +1,31 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using SurveyBackend.Core.Models; + +namespace SurveyBackend.Services.Helpers; + +public class TokenHelper +{ + public static string GetAuthToken(User user) + { + var userId = user.Id.ToString(); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId) + }; + + var jwt = new JwtSecurityToken( + claims: claims, + issuer: AuthOptions.Issuer, + audience: AuthOptions.Audience, + expires: DateTime.UtcNow + AuthOptions.TokenLifetime, + signingCredentials: new SigningCredentials(AuthOptions.SymmetricSecurityKey, SecurityAlgorithms.HmacSha256) + ); + + var token = new JwtSecurityTokenHandler().WriteToken(jwt); + + return token; + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Services/AuthorizationService.cs b/SurveyBackend/SurveyBackend.Services/Services/AuthorizationService.cs new file mode 100644 index 0000000..772eb27 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Services/AuthorizationService.cs @@ -0,0 +1,45 @@ +using SurveyBackend.Core.Models; +using SurveyBackend.Core.Services; +using SurveyBackend.Services.Exceptions; +using SurveyBackend.Services.Helpers; + +namespace SurveyBackend.Services.Services; + +public class AuthorizationService : IAuthorizationService +{ + private readonly IUserService _userService; + private readonly IPasswordHasher _passwordHasher; + + public AuthorizationService(IUserService userService, IPasswordHasher passwordHasher) + { + _userService = userService; + _passwordHasher = passwordHasher; + } + + public async Task LogInUser(string email, string password) + { + var user = await _userService.GetUserByEmail(email); + if (!_passwordHasher.Verify(password, user.Password)) + { + throw new UnauthorizedException("Password is incorrect."); + } + + var token = TokenHelper.GetAuthToken(user); + return token; + } + + public async Task RegisterUser(User user) + { + var isEmailTaken = await _userService.IsEmailTaken(user.Email); + if (isEmailTaken) + { + throw new ConflictException("Email already exists"); + } + + user.Password = _passwordHasher.HashPassword(user.Password); + + await _userService.CreateUserAsync(user); + var token = TokenHelper.GetAuthToken(user); + return token; + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Services/QuestionService.cs b/SurveyBackend/SurveyBackend.Services/Services/QuestionService.cs new file mode 100644 index 0000000..d62a28f --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Services/QuestionService.cs @@ -0,0 +1,63 @@ +using SurveyBackend.Core.Contexts; +using SurveyBackend.Services.Exceptions; +using SurveyLib.Core.Models; +using SurveyLib.Core.Repositories; +using SurveyLib.Core.Services; + +namespace SurveyBackend.Services.Services; + +public class QuestionService : IQuestionService +{ + private readonly IQuestionRepository _questionRepository; + private readonly ISurveyRepository _surveyRepository; + + public QuestionService(IQuestionRepository questionRepository, ISurveyRepository surveyRepository, + IUserContext userContext) + { + _questionRepository = questionRepository; + _surveyRepository = surveyRepository; + } + + public async Task AddQuestionAsync(QuestionBase question) + { + await _questionRepository.AddAsync(question); + } + + public async Task UpdateQuestionAsync(QuestionBase question) + { + await _questionRepository.UpdateAsync(question); + } + + public async Task DeleteQuestionAsync(int id) + { + var question = await _questionRepository.GetByIdAsync(id); + if (question is null) + { + throw new NotFoundException("Question not found"); + } + + await _questionRepository.DeleteAsync(question); + } + + public async Task GetQuestionByIdAsync(int id) + { + var question = await _questionRepository.GetByIdAsync(id); + if (question is null) + { + throw new NotFoundException("Question not found"); + } + + return question; + } + + public async Task> GetQuestionsBySurveyIdAsync(int surveyId) + { + var survey = await _surveyRepository.GetByIdAsync(surveyId); + if (survey is null) + { + throw new NotFoundException("Survey not found"); + } + + return await _questionRepository.GetQuestionsBySurveyId(surveyId); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Services/Sha256PasswordHasher.cs b/SurveyBackend/SurveyBackend.Services/Services/Sha256PasswordHasher.cs new file mode 100644 index 0000000..ed54508 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Services/Sha256PasswordHasher.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using SurveyBackend.Core.Services; + +namespace SurveyBackend.Services.Services; + +public class Sha256PasswordHasher : IPasswordHasher +{ + private const int RehashCount = 10000; + + private const int HashSizeBytes = 16; + + private const int SaltSizeBytes = 16; + + public string HashPassword(string password) + { + var saltBytes = RandomNumberGenerator.GetBytes(SaltSizeBytes); + + using var deriveBytes = GetDeriveBytes(password, saltBytes); + + var hashBytes = deriveBytes.GetBytes(HashSizeBytes); + + var fullHashBytes = new byte[SaltSizeBytes + HashSizeBytes]; + + Array.Copy(saltBytes, 0, fullHashBytes, 0, SaltSizeBytes); + Array.Copy(hashBytes, 0, fullHashBytes, SaltSizeBytes, HashSizeBytes); + + var hashedPassword = Convert.ToBase64String(fullHashBytes); + + return hashedPassword; + } + + public bool Verify(string password, string hashedPassword) + { + var fullHashBytes = Convert.FromBase64String(hashedPassword); + + var saltBytes = new byte[SaltSizeBytes]; + + Array.Copy(fullHashBytes, 0, saltBytes, 0, SaltSizeBytes); + + using var deriveBytes = GetDeriveBytes(password, saltBytes); + + var hashBytes = deriveBytes.GetBytes(HashSizeBytes); + + var isMatch = CompareHashes(hashBytes, fullHashBytes); + + return isMatch; + } + + private static bool CompareHashes(byte[] hashBytes, byte[] fullHashBytes) + { + for (var i = 0; i < HashSizeBytes; i++) + if (hashBytes[i] != fullHashBytes[i + SaltSizeBytes]) + return false; + + return true; + } + + private static Rfc2898DeriveBytes GetDeriveBytes(string password, byte[] saltBytes) + { + return new Rfc2898DeriveBytes(password, saltBytes, RehashCount, HashAlgorithmName.SHA256); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Services/SurveyService.cs b/SurveyBackend/SurveyBackend.Services/Services/SurveyService.cs new file mode 100644 index 0000000..9911697 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Services/SurveyService.cs @@ -0,0 +1,69 @@ +using SurveyBackend.Core.Contexts; +using SurveyBackend.Core.Repositories; +using SurveyBackend.Services.Exceptions; +using SurveyLib.Core.Models; +using SurveyLib.Core.Repositories; +using SurveyLib.Core.Services; + +namespace SurveyBackend.Services.Services; + +public class SurveyService : ISurveyService +{ + private readonly ISurveyRepository _surveyRepository; + private readonly IUserContext _userContext; + + public SurveyService(ISurveyRepository surveyRepository, IUserContext userContext) + { + _surveyRepository = surveyRepository; + _userContext = userContext; + } + + public async Task AddSurveyAsync(Survey survey) + { + await _surveyRepository.AddAsync(survey); + } + + public async Task UpdateSurveyAsync(Survey survey) + { + if (survey.CreatedBy != _userContext.UserId) + throw new UnauthorizedAccessException("You are not authorized to update this survey."); + await _surveyRepository.UpdateAsync(survey); + } + + public async Task DeleteSurveyAsync(int id) + { + var survey = await _surveyRepository.GetByIdAsync(id); + if (survey is null) + { + throw new NotFoundException("Survey not found"); + } + + if (survey.CreatedBy != _userContext.UserId) + { + throw new UnauthorizedAccessException("You are not authorized to delete this survey."); + } + + await _surveyRepository.DeleteAsync(survey); + } + + public async Task> GetSurveysAsync() + { + return await _surveyRepository.GetAllAsync(); + } + + public async Task GetSurveyAsync(int id) + { + var survey = await _surveyRepository.GetByIdAsync(id); + if (survey is null) + { + throw new NotFoundException("Survey not found"); + } + + return survey; + } + + public async Task> GetSurveysByUserIdAsync(int userId) + { + return await _surveyRepository.GetSurveysByUserIdAsync(userId); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/Services/UserService.cs b/SurveyBackend/SurveyBackend.Services/Services/UserService.cs new file mode 100644 index 0000000..9c52a64 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/Services/UserService.cs @@ -0,0 +1,31 @@ +using SurveyBackend.Core.Models; +using SurveyBackend.Core.Repositories; +using SurveyBackend.Core.Services; +using SurveyBackend.Services.Exceptions; + +namespace SurveyBackend.Services.Services; + +public class UserService : IUserService +{ + private readonly IUserRepository _userRepository; + + public UserService(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task GetUserByEmail(string email) + { + return await _userRepository.GetUserByEmail(email) ?? throw new NotFoundException("Email not found"); + } + + public async Task IsEmailTaken(string email) + { + return await _userRepository.GetUserByEmail(email) != null; + } + + public async Task CreateUserAsync(User user) + { + await _userRepository.AddAsync(user); + } +} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend.Services/SurveyBackend.Services.csproj b/SurveyBackend/SurveyBackend.Services/SurveyBackend.Services.csproj new file mode 100644 index 0000000..5b28c52 --- /dev/null +++ b/SurveyBackend/SurveyBackend.Services/SurveyBackend.Services.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/SurveyBackend/SurveyBackend.sln b/SurveyBackend/SurveyBackend.sln index b7d1a9a..56dba6f 100644 --- a/SurveyBackend/SurveyBackend.sln +++ b/SurveyBackend/SurveyBackend.sln @@ -1,6 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyBackend", "SurveyBackend\SurveyBackend.csproj", "{2941E98A-5311-4B97-B8B0-8DBF5E1C3B56}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyBackend.API", "SurveyBackend.API\SurveyBackend.API.csproj", "{2941E98A-5311-4B97-B8B0-8DBF5E1C3B56}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyBackend.Core", "SurveyBackend.Core\SurveyBackend.Core.csproj", "{596B4603-4066-4FF2-9C96-5357193F7229}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyBackend.Infrastructure", "SurveyBackend.Infrastructure\SurveyBackend.Infrastructure.csproj", "{4006471D-9F65-4AD6-852B-88A1211B49F4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyLib.Infrastructure.EFCore", "..\SurveyLib\SurveyLib.Infrastructure.EFCore\SurveyLib.Infrastructure.EFCore.csproj", "{CD9FE310-CDD1-4661-AB41-E606D35E1694}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyLib.Core", "..\SurveyLib\SurveyLib.Core\SurveyLib.Core.csproj", "{C17C405B-37CF-48E6-AA44-44B878F4DE56}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SurveyBackend.Services", "SurveyBackend.Services\SurveyBackend.Services.csproj", "{3CDA6495-4FB2-4F07-8B2F-15BFD2A35181}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -12,5 +22,25 @@ Global {2941E98A-5311-4B97-B8B0-8DBF5E1C3B56}.Debug|Any CPU.Build.0 = Debug|Any CPU {2941E98A-5311-4B97-B8B0-8DBF5E1C3B56}.Release|Any CPU.ActiveCfg = Release|Any CPU {2941E98A-5311-4B97-B8B0-8DBF5E1C3B56}.Release|Any CPU.Build.0 = Release|Any CPU + {596B4603-4066-4FF2-9C96-5357193F7229}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {596B4603-4066-4FF2-9C96-5357193F7229}.Debug|Any CPU.Build.0 = Debug|Any CPU + {596B4603-4066-4FF2-9C96-5357193F7229}.Release|Any CPU.ActiveCfg = Release|Any CPU + {596B4603-4066-4FF2-9C96-5357193F7229}.Release|Any CPU.Build.0 = Release|Any CPU + {4006471D-9F65-4AD6-852B-88A1211B49F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4006471D-9F65-4AD6-852B-88A1211B49F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4006471D-9F65-4AD6-852B-88A1211B49F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4006471D-9F65-4AD6-852B-88A1211B49F4}.Release|Any CPU.Build.0 = Release|Any CPU + {CD9FE310-CDD1-4661-AB41-E606D35E1694}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD9FE310-CDD1-4661-AB41-E606D35E1694}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD9FE310-CDD1-4661-AB41-E606D35E1694}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD9FE310-CDD1-4661-AB41-E606D35E1694}.Release|Any CPU.Build.0 = Release|Any CPU + {C17C405B-37CF-48E6-AA44-44B878F4DE56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C17C405B-37CF-48E6-AA44-44B878F4DE56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C17C405B-37CF-48E6-AA44-44B878F4DE56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C17C405B-37CF-48E6-AA44-44B878F4DE56}.Release|Any CPU.Build.0 = Release|Any CPU + {3CDA6495-4FB2-4F07-8B2F-15BFD2A35181}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CDA6495-4FB2-4F07-8B2F-15BFD2A35181}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CDA6495-4FB2-4F07-8B2F-15BFD2A35181}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CDA6495-4FB2-4F07-8B2F-15BFD2A35181}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/SurveyBackend/SurveyBackend/Program.cs b/SurveyBackend/SurveyBackend/Program.cs deleted file mode 100644 index 5a9ed2b..0000000 --- a/SurveyBackend/SurveyBackend/Program.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace SurveyBackend; - -public class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Add services to the container. - builder.Services.AddAuthorization(); - - // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(); - - var app = builder.Build(); - - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(); - } - - app.UseHttpsRedirection(); - - app.UseAuthorization(); - - var summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - app.MapGet("/weatherforecast", (HttpContext httpContext) => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast") - .WithOpenApi(); - - app.Run(); - } -} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend/WeatherForecast.cs b/SurveyBackend/SurveyBackend/WeatherForecast.cs deleted file mode 100644 index 75b15b6..0000000 --- a/SurveyBackend/SurveyBackend/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SurveyBackend; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} \ No newline at end of file diff --git a/SurveyBackend/SurveyBackend/appsettings.Development.json b/SurveyBackend/SurveyBackend/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/SurveyBackend/SurveyBackend/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/SurveyBackend/SurveyBackend/appsettings.json b/SurveyBackend/SurveyBackend/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/SurveyBackend/SurveyBackend/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/SurveyBackend/backend.gitlab-ci.yml b/SurveyBackend/backend.gitlab-ci.yml new file mode 100644 index 0000000..be8a91b --- /dev/null +++ b/SurveyBackend/backend.gitlab-ci.yml @@ -0,0 +1,59 @@ +stages: + - build + - test + - deploy + +backend-build-job: + image: mcr.microsoft.com/dotnet/sdk:8.0 + stage: build + script: + - dotnet restore SurveyBackend/SurveyBackend.sln + - dotnet tool install --global dotnet-ef + - export PATH="$PATH:/root/.dotnet/tools" + - > + dotnet ef migrations bundle + --self-contained + -r linux-x64 + -p SurveyBackend/SurveyBackend.Infrastructure/SurveyBackend.Infrastructure.csproj + -s ./SurveyBackend/SurveyBackend.API/SurveyBackend.API.csproj + -o $PUBLISH_DIR/efbundle + - > + dotnet publish SurveyBackend/SurveyBackend.API/SurveyBackend.API.csproj + -c Release -r linux-x64 --self-contained true + /p:PublishReadyToRun=true + /p:PublishSingleFile=true + /p:IncludeNativeLibrariesForSelfExtract=false + /p:TrimUnusedDependencies=false + -o $PUBLISH_DIR + artifacts: + paths: + - $PUBLISH_DIR + expire_in: 1 hour + +backend-test-job: + image: alpine:latest + stage: test + script: + - echo "Here we would run some tests if I knew how to use them" + +backend-deploy-job: + image: alpine:latest + stage: deploy + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + before_script: + - apk add --no-cache openssh + - mkdir -p ~/.ssh + - echo "$DEPLOY_KEY" | base64 -d > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + script: + - echo "Deploying backend to $DEPLOY_HOST" + - scp -r $PUBLISH_DIR/. $DEPLOY_USER@$DEPLOY_HOST:$BACKEND_DEPLOY_PATH + - > + ssh $DEPLOY_USER@$DEPLOY_HOST " + cd $BACKEND_DEPLOY_PATH && + echo 'Running migrations' && + ./efbundle && + echo 'Restarting systemd service' && + systemctl --user restart SurveyBackend.service" \ No newline at end of file diff --git a/SurveyFrontend/frontend.gitlab-ci.yml b/SurveyFrontend/frontend.gitlab-ci.yml new file mode 100644 index 0000000..d5736da --- /dev/null +++ b/SurveyFrontend/frontend.gitlab-ci.yml @@ -0,0 +1,37 @@ +stages: + - build + - test + - deploy + +frontend-build-job: + image: node:20-alpine + stage: build + script: + - cd SurveyFrontend + - npm install + - npm run build + artifacts: + paths: + - SurveyFrontend/dist + expire_in: 1 hour + +frontend-test-job: + image: alpine:latest + stage: test + script: + - echo "Here we would run some tests if I knew how to use them" + +frontend-deploy-job: + image: alpine:latest + stage: deploy + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + before_script: + - apk add --no-cache openssh + - mkdir -p ~/.ssh + - echo "$DEPLOY_KEY" | base64 -d > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + script: + - echo "Deploying frontend to $DEPLOY_HOST" + - scp -r SurveyFrontend/dist/* $DEPLOY_USER@$DEPLOY_HOST:$FRONTEND_DEPLOY_PATH diff --git a/SurveyFrontend/index.html b/SurveyFrontend/index.html index e4b78ea..7cebcf7 100644 --- a/SurveyFrontend/index.html +++ b/SurveyFrontend/index.html @@ -2,9 +2,12 @@ - + + + + - Vite + React + TS + Survey
diff --git a/SurveyFrontend/package-lock.json b/SurveyFrontend/package-lock.json index e34fc27..38b21e5 100644 --- a/SurveyFrontend/package-lock.json +++ b/SurveyFrontend/package-lock.json @@ -8,8 +8,10 @@ "name": "survey-frontend", "version": "0.0.0", "dependencies": { + "@formkit/tempo": "^0.1.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -841,6 +843,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formkit/tempo": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@formkit/tempo/-/tempo-0.1.2.tgz", + "integrity": "sha512-jNPPbjL8oj7hK3eHX++CwbR6X4GKQt+x00/q4yeXkwynXHGKL27dylYhpEgwrmediPP4y7s0XtN1if/M/JYujg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2876,6 +2884,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.2.tgz", diff --git a/SurveyFrontend/package.json b/SurveyFrontend/package.json index e589ff7..150e7f9 100644 --- a/SurveyFrontend/package.json +++ b/SurveyFrontend/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@formkit/tempo": "^0.1.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/SurveyFrontend/public/account.svg b/SurveyFrontend/public/account.svg new file mode 100644 index 0000000..031e074 --- /dev/null +++ b/SurveyFrontend/public/account.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/add_answer.svg b/SurveyFrontend/public/add_answer.svg new file mode 100644 index 0000000..5690985 --- /dev/null +++ b/SurveyFrontend/public/add_answer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SurveyFrontend/public/add_circle.svg b/SurveyFrontend/public/add_circle.svg new file mode 100644 index 0000000..35a1a64 --- /dev/null +++ b/SurveyFrontend/public/add_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/add_question.svg b/SurveyFrontend/public/add_question.svg new file mode 100644 index 0000000..d74cec8 --- /dev/null +++ b/SurveyFrontend/public/add_question.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/SurveyFrontend/public/arrow_drop_down.svg b/SurveyFrontend/public/arrow_drop_down.svg new file mode 100644 index 0000000..1d700de --- /dev/null +++ b/SurveyFrontend/public/arrow_drop_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/arrow_drop_up.svg b/SurveyFrontend/public/arrow_drop_up.svg new file mode 100644 index 0000000..d891272 --- /dev/null +++ b/SurveyFrontend/public/arrow_drop_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/check_box.svg b/SurveyFrontend/public/check_box.svg new file mode 100644 index 0000000..080e32c --- /dev/null +++ b/SurveyFrontend/public/check_box.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/delete.svg b/SurveyFrontend/public/delete.svg new file mode 100644 index 0000000..47f0621 --- /dev/null +++ b/SurveyFrontend/public/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/deleteQuestion.svg b/SurveyFrontend/public/deleteQuestion.svg new file mode 100644 index 0000000..8766ff1 --- /dev/null +++ b/SurveyFrontend/public/deleteQuestion.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/emptyCheckbox.svg b/SurveyFrontend/public/emptyCheckbox.svg new file mode 100644 index 0000000..f61b1f8 --- /dev/null +++ b/SurveyFrontend/public/emptyCheckbox.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SurveyFrontend/public/logo.svg b/SurveyFrontend/public/logo.svg new file mode 100644 index 0000000..f030325 --- /dev/null +++ b/SurveyFrontend/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/radio_button_checked.svg b/SurveyFrontend/public/radio_button_checked.svg new file mode 100644 index 0000000..9c32c52 --- /dev/null +++ b/SurveyFrontend/public/radio_button_checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/public/radio_button_unchecked.svg b/SurveyFrontend/public/radio_button_unchecked.svg new file mode 100644 index 0000000..a99050b --- /dev/null +++ b/SurveyFrontend/public/radio_button_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/SurveyFrontend/src/App.css b/SurveyFrontend/src/App.css index b9d355d..ce44332 100644 --- a/SurveyFrontend/src/App.css +++ b/SurveyFrontend/src/App.css @@ -1,42 +1,3 @@ #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} + width: 100%; +} \ No newline at end of file diff --git a/SurveyFrontend/src/App.tsx b/SurveyFrontend/src/App.tsx index 3d7ded3..f5a93c5 100644 --- a/SurveyFrontend/src/App.tsx +++ b/SurveyFrontend/src/App.tsx @@ -1,35 +1,12 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import React from 'react'; import './App.css' +import Questions from './pages/Questions.tsx' -function App() { - const [count, setCount] = useState(0) - - return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) +const App: React.FC = () => { + return ( + + ) } -export default App + +export default App \ No newline at end of file diff --git a/SurveyFrontend/src/components/Account/Account.module.css b/SurveyFrontend/src/components/Account/Account.module.css new file mode 100644 index 0000000..cbbfd5e --- /dev/null +++ b/SurveyFrontend/src/components/Account/Account.module.css @@ -0,0 +1,24 @@ +/*Account.module.css*/ + +.account { + background-color: #F3F3F3; + border-radius: 40px; + align-items: center; + padding: 4.58px 13px 4.58px 4.58px; + margin: 26px 33px 27px 0; + margin-left: auto; +} + +.accountText{ + font-size: 24px; + font-weight: 600; + color: black; + width: 100%; + text-decoration: none; +} + +.accountImg{ + vertical-align: middle; + width: 55px; + margin-right: 9px; +} \ No newline at end of file diff --git a/SurveyFrontend/src/components/Account/Account.tsx b/SurveyFrontend/src/components/Account/Account.tsx new file mode 100644 index 0000000..b859db4 --- /dev/null +++ b/SurveyFrontend/src/components/Account/Account.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styles from './Account.module.css' + +interface AccountProps { + href: string; + user: string; +} + +const Account: React.FC = ({href, user}) => { + return ( + + ); +}; + +export default Account; \ No newline at end of file diff --git a/SurveyFrontend/src/components/AddAnswerButton/AddAnswerButton.module.css b/SurveyFrontend/src/components/AddAnswerButton/AddAnswerButton.module.css new file mode 100644 index 0000000..297d221 --- /dev/null +++ b/SurveyFrontend/src/components/AddAnswerButton/AddAnswerButton.module.css @@ -0,0 +1,17 @@ +/*AddAnswerButton.module.css*/ + +.answerButton { + margin-top: 18px; + display: flex; + gap: 10px; + align-items: center; + border: none; + background-color: white; + color: #3788D6; + font-size: 18px; + font-weight: 500; +} + +.addAnswerImg{ + vertical-align: middle; +} \ No newline at end of file diff --git a/SurveyFrontend/src/components/AddAnswerButton/AddAnswerButton.tsx b/SurveyFrontend/src/components/AddAnswerButton/AddAnswerButton.tsx new file mode 100644 index 0000000..901ea80 --- /dev/null +++ b/SurveyFrontend/src/components/AddAnswerButton/AddAnswerButton.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import styles from './AddAnswerButton.module.css' + + +interface AddAnswerButtonProps { + onClick(): void; +} + +const AddAnswerButton: React.FC = ({onClick}) => { + return ( + + ); +}; + +export default AddAnswerButton; \ No newline at end of file diff --git a/SurveyFrontend/src/components/AddQuestionButton/AddQuestionButton.module.css b/SurveyFrontend/src/components/AddQuestionButton/AddQuestionButton.module.css new file mode 100644 index 0000000..aa8331c --- /dev/null +++ b/SurveyFrontend/src/components/AddQuestionButton/AddQuestionButton.module.css @@ -0,0 +1,24 @@ +/*AddQuestionButton.module.css*/ + +.questionButton{ + display: block; + margin: 0 auto; + display: flex; + flex-direction: column; + margin-bottom: 80px; + align-items: center; + background-color: #F6F6F6; + border: none; + outline: none; +} + +.questionButtonImg{ + width: 54px; + align-items: center; +} + +.textButton{ + font-size: 24px; + font-weight: 600; + text-align: center; +} \ No newline at end of file diff --git a/SurveyFrontend/src/components/AddQuestionButton/AddQuestionButton.tsx b/SurveyFrontend/src/components/AddQuestionButton/AddQuestionButton.tsx new file mode 100644 index 0000000..667ec93 --- /dev/null +++ b/SurveyFrontend/src/components/AddQuestionButton/AddQuestionButton.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import styles from './AddQuestionButton.module.css' + +interface AddQuestionButtonProps { + onClick: () => void; +} + +const AddQuestionButton: React.FC = ({onClick}) => { + return ( + + ); +}; + +export default AddQuestionButton; \ No newline at end of file diff --git a/SurveyFrontend/src/components/AnswerOption/AnswerOption.module.css b/SurveyFrontend/src/components/AnswerOption/AnswerOption.module.css new file mode 100644 index 0000000..6a0ec90 --- /dev/null +++ b/SurveyFrontend/src/components/AnswerOption/AnswerOption.module.css @@ -0,0 +1,48 @@ +/*AnswerOption.module.css*/ + +.answer{ + width: 100%; + display: flex; + gap: 10px; + margin-bottom: 17px; +} + +.textAnswer{ + text-align: left; + border: none; + background-color: #ffffff; + font-size: 18px; + font-weight: 500; + word-break: break-word; + width: 70%; +} + +.buttonMarker{ + padding: 0; + border: none; + background-color: transparent; +} + +.answerIcon{ + vertical-align: middle; + width: 24px; +} + +.answerInput{ + vertical-align: middle; + font-size: 18px; + font-weight: 500; + outline: none; + border: none; + resize: none; + display: flex; + align-items: center; + box-sizing: border-box; +} + +.deleteButton{ + margin-left: auto; + border: none; + background-color: transparent; + padding: 0; +} \ No newline at end of file diff --git a/SurveyFrontend/src/components/AnswerOption/AnswerOption.tsx b/SurveyFrontend/src/components/AnswerOption/AnswerOption.tsx new file mode 100644 index 0000000..e0c78b1 --- /dev/null +++ b/SurveyFrontend/src/components/AnswerOption/AnswerOption.tsx @@ -0,0 +1,97 @@ +import React, {useState, useRef, useEffect} from "react"; +import styles from'./AnswerOption.module.css'; + +const single_selected_response = '../../../public/radio_button_checked.svg'; +const multiple_selected_response = '../../../public/check_box.svg'; +const single_response = '../../../public/radio_button_unchecked.svg'; +const multiple_response ='../../../public/emptyCheckbox.svg'; + +interface AnswerOptionProps{ + index: number; + value: string; + onChange: (value: string) => void; + onDelete:(index: number) => void; + selectedType: 'single' | 'multiply'; + isSelected: boolean; + toggleSelect: () => void; +} + +const AnswerOption: React.FC = ({index, value, onChange, onDelete, selectedType, isSelected, toggleSelect}) => { + const [currentValue, setCurrentValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); + + const textAreaRef = useRef(null); + + useEffect(() => { + setCurrentValue(value); + }, [value]); + + const handleSpanClick = () => { + setIsEditing(true); + } + + const handleTextareaChange = (event: React.ChangeEvent) => { + setCurrentValue(event.target.value); + }; + + const handleSave = () => { + setIsEditing(false); + onChange(currentValue); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + handleSave(); + } + }; + + const handleBlur = () => { + handleSave(); + }; + + useEffect(() => { + if (isEditing && textAreaRef.current) { + textAreaRef.current.focus(); + } + }, [isEditing]); + + const getImage = () => { + if (selectedType === 'multiply') { + return isSelected ? multiple_selected_response : multiple_response; + } else { + return isSelected ? single_selected_response : single_response; + } + }; + + return ( +
+ + {isEditing ? ( +