From 6f3acce7d4433269e89f35edb204fa50a99a3f89 Mon Sep 17 00:00:00 2001 From: farcasclaudiu Date: Thu, 6 Oct 2022 16:53:07 +0300 Subject: [PATCH] Add project files. --- .dockerignore | 25 ++++ .../Controllers/EventsController.cs | 107 +++++++++++++++++ PracticeCalendar.Api/Dockerfile | 22 ++++ PracticeCalendar.Api/Model/EventModel.cs | 34 ++++++ .../PracticeCalendar.API.csproj | 22 ++++ PracticeCalendar.Api/Program.cs | 69 +++++++++++ .../Properties/launchSettings.json | 38 ++++++ .../appsettings.Development.json | 8 ++ PracticeCalendar.Api/appsettings.json | 13 +++ PracticeCalendar.Api/practicecalendar.sqlite | Bin 0 -> 20480 bytes .../Common/DomainEventBase.cs | 7 ++ .../Common/DomainException.cs | 6 + PracticeCalendar.Domain/Common/EntityBase.cs | 16 +++ .../Common/IgnoreMemberAttribute.cs | 8 ++ .../Common/Interfaces/IAggregateRoot.cs | 6 + .../Common/Interfaces/IRepository.cs | 8 ++ PracticeCalendar.Domain/Common/ValueObject.cs | 110 ++++++++++++++++++ PracticeCalendar.Domain/Entities/Attendee.cs | 44 +++++++ .../Entities/PracticeEvent.cs | 63 ++++++++++ .../PracticeEventByIdWithAttendees.cs | 13 +++ .../PracticeEventsWithAttendees.cs | 12 ++ .../Events/AttendeeAddedEvent.cs | 17 +++ .../Exceptions/InvalidAttendeeException.cs | 16 +++ .../Interfaces/IEmailSender.cs | 7 ++ .../PracticeCalendar.Domain.csproj | 19 +++ .../InfrastructureDI.cs | 30 +++++ .../Notification/FileEmailSender.cs | 21 ++++ .../Persistence/ApplicationDbContext.cs | 23 ++++ .../ApplicationDbContextInitialiser.cs | 65 +++++++++++ .../Configuration/AttendeeConfiguration.cs | 19 +++ .../PracticeEventConfiguration.cs | 19 +++ .../Persistence/EfRepository.cs | 12 ++ .../PracticeCalendar.Infrastructure.csproj | 19 +++ .../PracticeCalendar.UnitTests.csproj | 29 +++++ .../PracticeEventTest.cs | 56 +++++++++ PracticeCalendar.UnitTests/Usings.cs | 1 + PracticeCalendar.sln | 48 ++++++++ README.md | 19 +++ 38 files changed, 1051 insertions(+) create mode 100644 .dockerignore create mode 100644 PracticeCalendar.Api/Controllers/EventsController.cs create mode 100644 PracticeCalendar.Api/Dockerfile create mode 100644 PracticeCalendar.Api/Model/EventModel.cs create mode 100644 PracticeCalendar.Api/PracticeCalendar.API.csproj create mode 100644 PracticeCalendar.Api/Program.cs create mode 100644 PracticeCalendar.Api/Properties/launchSettings.json create mode 100644 PracticeCalendar.Api/appsettings.Development.json create mode 100644 PracticeCalendar.Api/appsettings.json create mode 100644 PracticeCalendar.Api/practicecalendar.sqlite create mode 100644 PracticeCalendar.Domain/Common/DomainEventBase.cs create mode 100644 PracticeCalendar.Domain/Common/DomainException.cs create mode 100644 PracticeCalendar.Domain/Common/EntityBase.cs create mode 100644 PracticeCalendar.Domain/Common/IgnoreMemberAttribute.cs create mode 100644 PracticeCalendar.Domain/Common/Interfaces/IAggregateRoot.cs create mode 100644 PracticeCalendar.Domain/Common/Interfaces/IRepository.cs create mode 100644 PracticeCalendar.Domain/Common/ValueObject.cs create mode 100644 PracticeCalendar.Domain/Entities/Attendee.cs create mode 100644 PracticeCalendar.Domain/Entities/PracticeEvent.cs create mode 100644 PracticeCalendar.Domain/Entities/Specifications/PracticeEventByIdWithAttendees.cs create mode 100644 PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs create mode 100644 PracticeCalendar.Domain/Events/AttendeeAddedEvent.cs create mode 100644 PracticeCalendar.Domain/Exceptions/InvalidAttendeeException.cs create mode 100644 PracticeCalendar.Domain/Interfaces/IEmailSender.cs create mode 100644 PracticeCalendar.Domain/PracticeCalendar.Domain.csproj create mode 100644 PracticeCalendar.Infrastructure/InfrastructureDI.cs create mode 100644 PracticeCalendar.Infrastructure/Notification/FileEmailSender.cs create mode 100644 PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs create mode 100644 PracticeCalendar.Infrastructure/Persistence/ApplicationDbContextInitialiser.cs create mode 100644 PracticeCalendar.Infrastructure/Persistence/Configuration/AttendeeConfiguration.cs create mode 100644 PracticeCalendar.Infrastructure/Persistence/Configuration/PracticeEventConfiguration.cs create mode 100644 PracticeCalendar.Infrastructure/Persistence/EfRepository.cs create mode 100644 PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj create mode 100644 PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj create mode 100644 PracticeCalendar.UnitTests/PracticeEventTest.cs create mode 100644 PracticeCalendar.UnitTests/Usings.cs create mode 100644 PracticeCalendar.sln create mode 100644 README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3729ff0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/PracticeCalendar.Api/Controllers/EventsController.cs b/PracticeCalendar.Api/Controllers/EventsController.cs new file mode 100644 index 0000000..506dde0 --- /dev/null +++ b/PracticeCalendar.Api/Controllers/EventsController.cs @@ -0,0 +1,107 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using PracticeCalendar.API.Model; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; +using PracticeCalendar.Domain.Entities.Specifications; + +namespace PrcaticeCalendar.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class EventsController : ControllerBase + { + private readonly IRepository eventsRepo; + private readonly IMapper mapper; + private readonly ILogger _logger; + + public EventsController(IRepository eventsRepo, + IMapper mapper, + ILogger logger) + { + this.eventsRepo = eventsRepo; + this.mapper = mapper; + _logger = logger; + } + + [HttpGet(Name = "GetAll")] + public async Task>> Get() + { + var spec = new PracticeEventsWithAttendees(); + var repoList = await eventsRepo.ListAsync(spec); + var evList = repoList.Select(x=> { + var model = mapper.Map(x); + model.Attendees = x.Attendees.Select(m=>mapper.Map(m)).ToArray(); + return model; + }) + .ToList(); + return evList; + } + + [HttpPost(Name = "Create practice event")] + public async Task CreateEvent(EventModel eventModel) + { + var practiceEvent = new PracticeEvent(eventModel.Title, eventModel.Description); + foreach (var att in eventModel.Attendees) + { + practiceEvent.AddAttendee(new Attendee(att.Name, att.EmailAddress)); + } + var result = await eventsRepo.AddAsync(practiceEvent); + await eventsRepo.SaveChangesAsync(); + return Ok(mapper.Map(result)); + } + + [HttpPut(Name = "Update practice event")] + public async Task UpdateEvent(EventModel eventModel) + { + var practiceEvent = await eventsRepo.GetByIdAsync(eventModel.Id); + if(practiceEvent == null) + { + return NotFound(); + } + practiceEvent.UpdateTitleAndDescription(eventModel.Title, eventModel.Description); + await eventsRepo.UpdateAsync(practiceEvent); + await eventsRepo.SaveChangesAsync(); + return Ok(mapper.Map(practiceEvent)); + } + + [HttpDelete(Name = "Delete practice event")] + public async Task DeleteEvent(int practiceEventId) + { + var org = await eventsRepo.GetByIdAsync(practiceEventId); + await eventsRepo.DeleteAsync(org); + await eventsRepo.SaveChangesAsync(); + return Ok(); + } + + [HttpPost] + [Route("accept/{eventId}/{attendeeId}")] + public async Task AttendeeAcceptEvent(int eventId, int attendeeId) + { + var spec = new PracticeEventByIdWithAttendees(eventId); + var practiceEvent = await eventsRepo.GetBySpecAsync(spec); + if (practiceEvent == null) + { + return NotFound(); + } + practiceEvent.AttendeeAcceptEvent(attendeeId); + await eventsRepo.SaveChangesAsync(); + return Ok(); + } + + [HttpPost] + [Route("decline/{eventId}/{attendeeId}")] + public async Task AttendeeDeclineEvent(int eventId, int attendeeId) + { + var spec = new PracticeEventByIdWithAttendees(eventId); + var practiceEvent = await eventsRepo.GetBySpecAsync(spec); + if (practiceEvent == null) + { + return NotFound(); + } + practiceEvent.AttendeeDeclineEvent(attendeeId); + await eventsRepo.SaveChangesAsync(); + return Ok(); + } + } +} \ No newline at end of file diff --git a/PracticeCalendar.Api/Dockerfile b/PracticeCalendar.Api/Dockerfile new file mode 100644 index 0000000..aee240a --- /dev/null +++ b/PracticeCalendar.Api/Dockerfile @@ -0,0 +1,22 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /src +COPY ["PrcaticeCalendar/PrcaticeCalendar.csproj", "PrcaticeCalendar/"] +RUN dotnet restore "PrcaticeCalendar/PrcaticeCalendar.csproj" +COPY . . +WORKDIR "/src/PrcaticeCalendar" +RUN dotnet build "PrcaticeCalendar.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "PrcaticeCalendar.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PrcaticeCalendar.dll"] \ No newline at end of file diff --git a/PracticeCalendar.Api/Model/EventModel.cs b/PracticeCalendar.Api/Model/EventModel.cs new file mode 100644 index 0000000..8738d4a --- /dev/null +++ b/PracticeCalendar.Api/Model/EventModel.cs @@ -0,0 +1,34 @@ +using AutoMapper; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.API.Model +{ + public class EventModel + { + public int Id { get; set; } + public string Title { get; set; } + public string Description { get; set; } + + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + + public AttendeeModel[] Attendees { get; set; } = new AttendeeModel[0]; + } + + public class AttendeeModel + { + public string Name { get; set; } + public string EmailAddress { get; set; } + public bool IsAttending { get; set; } + } + + public class EventModelProfile : Profile + { + public EventModelProfile() + { + CreateMap(); + CreateMap(); + CreateMap(); + } + } +} diff --git a/PracticeCalendar.Api/PracticeCalendar.API.csproj b/PracticeCalendar.Api/PracticeCalendar.API.csproj new file mode 100644 index 0000000..d9e4b7b --- /dev/null +++ b/PracticeCalendar.Api/PracticeCalendar.API.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + 78351d12-8544-4ae0-877a-3522681401ca + Linux + + + + + + + + + + + + + + diff --git a/PracticeCalendar.Api/Program.cs b/PracticeCalendar.Api/Program.cs new file mode 100644 index 0000000..773fe32 --- /dev/null +++ b/PracticeCalendar.Api/Program.cs @@ -0,0 +1,69 @@ +using Hellang.Middleware.ProblemDetails; +using Microsoft.AspNetCore.Hosting; +using PracticeCalendar.Infrastructure; +using PracticeCalendar.Infrastructure.Persistence; +using System; + +namespace PracticeCalendar +{ + public class Program + { + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + + builder.Services.AddProblemDetails(); + + builder.Services.AddAutoMapper(typeof(Program)); + builder.Services.AddControllers(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + + //inject infrastructure + builder.Services.AddInfrastructure(builder.Configuration); + + var app = builder.Build(); + + app.UseProblemDetails(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + + app.MapControllers(); + + + // Seed Database + using (var scope = app.Services.CreateScope()) + { + var services = scope.ServiceProvider; + + try + { + var context = services.GetRequiredService(); + //context.Database.Migrate(); + context.Database.EnsureCreated(); + //SeedData.Initialize(services); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred seeding the DB. {exceptionMessage}", ex.Message); + } + } + + app.Run(); + } + } +} \ No newline at end of file diff --git a/PracticeCalendar.Api/Properties/launchSettings.json b/PracticeCalendar.Api/Properties/launchSettings.json new file mode 100644 index 0000000..a09671e --- /dev/null +++ b/PracticeCalendar.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "profiles": { + "PracticeCalendar": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7267;http://localhost:5267" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Docker": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:58588", + "sslPort": 44388 + } + } +} \ No newline at end of file diff --git a/PracticeCalendar.Api/appsettings.Development.json b/PracticeCalendar.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/PracticeCalendar.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/PracticeCalendar.Api/appsettings.json b/PracticeCalendar.Api/appsettings.json new file mode 100644 index 0000000..83a9a57 --- /dev/null +++ b/PracticeCalendar.Api/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "UseInMemoryDatabase": true, + "ConnectionStrings": { + "SqliteConnection": "Data Source=practicecalendar.sqlite" + } +} diff --git a/PracticeCalendar.Api/practicecalendar.sqlite b/PracticeCalendar.Api/practicecalendar.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..b7646ed4573d62b8ce8701cdf29a3f17260a5139 GIT binary patch literal 20480 zcmeI&yKmD#90zb;{OG$zpel;0h?6Byqdw#&w3L7B#c zLD8ov;haE-aD!4o5XSf}^F2r+FG#@&&q+slP;pFH$UUCmSEX@Jn0z<+Z2SW+AVB~E z5P$##AOHafKmY>&x4+WAq+rf=i@xaxn!*=;2+m8({v4f&lG0NqdTdxne#jG2CL)oj|?d^WMoz6jQ z{2WAhR(d8$qZ>J!nVVK~jD5hV%{G|LtTZcdoX#2Mot7~{knT}->sJ1u2V5IX&tX8 zxtdy9UAne#^~%bCaO3+wqP`W>&-{P{0SG_<0uX=z1Rwwb2tWV=5P-lL6c~?)aS|+Y zkT^N;q`pyKsV{gD2?7v+00bZa0SG_<0uX=z1Rwx`zb`Pw7a@>% zY*=ZpU084BQ+$O1503<4qZPiumk*FwY*=ocwu5|SKqDxHf}|A`JA _domainEvents = new(); + [NotMapped] + public IEnumerable DomainEvents => _domainEvents.AsReadOnly(); + + protected void RegisterDomainEvent(DomainEventBase domainEvent) => _domainEvents.Add(domainEvent); + internal void ClearDomainEvents() => _domainEvents.Clear(); + } +} diff --git a/PracticeCalendar.Domain/Common/IgnoreMemberAttribute.cs b/PracticeCalendar.Domain/Common/IgnoreMemberAttribute.cs new file mode 100644 index 0000000..7452fd2 --- /dev/null +++ b/PracticeCalendar.Domain/Common/IgnoreMemberAttribute.cs @@ -0,0 +1,8 @@ +namespace PracticeCalendar.Domain.Common +{ + // source: https://github.com/jhewlett/ValueObject + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public class IgnoreMemberAttribute : Attribute + { + } +} diff --git a/PracticeCalendar.Domain/Common/Interfaces/IAggregateRoot.cs b/PracticeCalendar.Domain/Common/Interfaces/IAggregateRoot.cs new file mode 100644 index 0000000..7780813 --- /dev/null +++ b/PracticeCalendar.Domain/Common/Interfaces/IAggregateRoot.cs @@ -0,0 +1,6 @@ +namespace PracticeCalendar.Domain.Common.Interfaces +{ + // Apply this marker interface only to aggregate root entities + // Repositories will only work with aggregate roots, not their children + public interface IAggregateRoot { } +} diff --git a/PracticeCalendar.Domain/Common/Interfaces/IRepository.cs b/PracticeCalendar.Domain/Common/Interfaces/IRepository.cs new file mode 100644 index 0000000..0892dd0 --- /dev/null +++ b/PracticeCalendar.Domain/Common/Interfaces/IRepository.cs @@ -0,0 +1,8 @@ +using Ardalis.Specification; + +namespace PracticeCalendar.Domain.Common.Interfaces +{ + public interface IRepository : IRepositoryBase where T : class, IAggregateRoot + { + } +} diff --git a/PracticeCalendar.Domain/Common/ValueObject.cs b/PracticeCalendar.Domain/Common/ValueObject.cs new file mode 100644 index 0000000..2afcbaa --- /dev/null +++ b/PracticeCalendar.Domain/Common/ValueObject.cs @@ -0,0 +1,110 @@ +using System.Reflection; + +namespace PracticeCalendar.Domain.Common +{ + // source: https://github.com/jhewlett/ValueObject + public abstract class ValueObject : IEquatable + { + private List? properties; + private List? fields; + + public static bool operator ==(ValueObject? obj1, ValueObject? obj2) + { + if (object.Equals(obj1, null)) + { + if (object.Equals(obj2, null)) + { + return true; + } + return false; + } + return obj1.Equals(obj2); + } + + public static bool operator !=(ValueObject? obj1, ValueObject? obj2) + { + return !(obj1 == obj2); + } + + public bool Equals(ValueObject? obj) + { + return Equals(obj as object); + } + + public override bool Equals(object? obj) + { + if (obj == null || GetType() != obj.GetType()) return false; + + return GetProperties().All(p => PropertiesAreEqual(obj, p)) + && GetFields().All(f => FieldsAreEqual(obj, f)); + } + + private bool PropertiesAreEqual(object obj, PropertyInfo p) + { + return object.Equals(p.GetValue(this, null), p.GetValue(obj, null)); + } + + private bool FieldsAreEqual(object obj, FieldInfo f) + { + return object.Equals(f.GetValue(this), f.GetValue(obj)); + } + + private IEnumerable GetProperties() + { + if (this.properties == null) + { + this.properties = GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetCustomAttribute(typeof(IgnoreMemberAttribute)) == null) + .ToList(); + + // Not available in Core + // !Attribute.IsDefined(p, typeof(IgnoreMemberAttribute))).ToList(); + } + + return this.properties; + } + + private IEnumerable GetFields() + { + if (this.fields == null) + { + this.fields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetCustomAttribute(typeof(IgnoreMemberAttribute)) == null) + .ToList(); + } + + return this.fields; + } + + public override int GetHashCode() + { + unchecked //allow overflow + { + int hash = 17; + foreach (var prop in GetProperties()) + { + var value = prop.GetValue(this, null); + hash = HashValue(hash, value); + } + + foreach (var field in GetFields()) + { + var value = field.GetValue(this); + hash = HashValue(hash, value); + } + + return hash; + } + } + + private int HashValue(int seed, object? value) + { + var currentHash = value != null + ? value.GetHashCode() + : 0; + + return seed * 23 + currentHash; + } + } +} diff --git a/PracticeCalendar.Domain/Entities/Attendee.cs b/PracticeCalendar.Domain/Entities/Attendee.cs new file mode 100644 index 0000000..d376b21 --- /dev/null +++ b/PracticeCalendar.Domain/Entities/Attendee.cs @@ -0,0 +1,44 @@ +using Ardalis.GuardClauses; +using PracticeCalendar.Domain.Common; +using System.Diagnostics.Contracts; + +namespace PracticeCalendar.Domain.Entities +{ + /// + /// Attendee to an event + /// + public class Attendee: EntityBase + { + public Attendee(string name, string emailAddress) + { + Guard.Against.NullOrEmpty(name); + Guard.Against.NullOrEmpty(emailAddress); + Name = name; + EmailAddress = emailAddress; + } + + public int PracticeEventId { get; private set; } + public string Name { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + public bool IsAttending { get; private set; } + + /// + /// Set if the Attendee is attending + /// + /// + public void SetIsAttending (bool isAttending) + { + this.IsAttending = isAttending; + //TODO - raise event + } + + /// + /// Assign Attendee to the practice event + /// + /// + public void AssignToEvent(int practiceEventId) + { + this.PracticeEventId = practiceEventId; + } + } +} diff --git a/PracticeCalendar.Domain/Entities/PracticeEvent.cs b/PracticeCalendar.Domain/Entities/PracticeEvent.cs new file mode 100644 index 0000000..5da71ad --- /dev/null +++ b/PracticeCalendar.Domain/Entities/PracticeEvent.cs @@ -0,0 +1,63 @@ +using Ardalis.GuardClauses; +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Events; +using PracticeCalendar.Domain.Exceptions; + +namespace PracticeCalendar.Domain.Entities +{ + /// + /// Practice event aggregate + /// + public class PracticeEvent : EntityBase, IAggregateRoot + { + public PracticeEvent(string title, string description) + { + Guard.Against.NullOrEmpty(title, nameof(title)); + Guard.Against.NullOrEmpty(description, nameof(description)); + this.Title = title; + this.Description = description; + } + + public string Title { get; private set; } = string.Empty; + public string Description{ get; private set; } = string.Empty; + + private List attendees = new List(); + public IEnumerable Attendees => attendees.AsReadOnly(); + + public DateTime StartTime { get; private set; } + public DateTime EndTime { get; private set; } + + public void AddAttendee(Attendee attendee) + { + Guard.Against.Null(attendee, nameof(attendee)); + attendee.AssignToEvent(this.Id); + attendees.Add(attendee); + + var attendeeAddedEvent = new AttendeeAddedEvent(this, attendee); + base.RegisterDomainEvent(attendeeAddedEvent); + } + + public void UpdateTitleAndDescription(string title, string description) + { + this.Title = title; + this.Description = description; + } + + public void AttendeeAcceptEvent(int attendeeId) + { + var attendee = this.Attendees.FirstOrDefault(x => x.Id == attendeeId); + if (attendee == null) + throw new InvalidAttendeeException(attendeeId); + attendee.SetIsAttending(true); + } + + public void AttendeeDeclineEvent(int attendeeId) + { + var attendee = this.Attendees.FirstOrDefault(x => x.Id == attendeeId); + if (attendee == null) + throw new InvalidAttendeeException(attendeeId); + attendee.SetIsAttending(false); + } + } +} diff --git a/PracticeCalendar.Domain/Entities/Specifications/PracticeEventByIdWithAttendees.cs b/PracticeCalendar.Domain/Entities/Specifications/PracticeEventByIdWithAttendees.cs new file mode 100644 index 0000000..93c6f93 --- /dev/null +++ b/PracticeCalendar.Domain/Entities/Specifications/PracticeEventByIdWithAttendees.cs @@ -0,0 +1,13 @@ +using Ardalis.Specification; + +namespace PracticeCalendar.Domain.Entities.Specifications +{ + public class PracticeEventByIdWithAttendees : Specification + { + public PracticeEventByIdWithAttendees(int eventId) + { + Query.Where(x=>x.Id == eventId) + .Include(x => x.Attendees); + } + } +} diff --git a/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs b/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs new file mode 100644 index 0000000..11ded62 --- /dev/null +++ b/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs @@ -0,0 +1,12 @@ +using Ardalis.Specification; + +namespace PracticeCalendar.Domain.Entities.Specifications +{ + public class PracticeEventsWithAttendees : Specification + { + public PracticeEventsWithAttendees() + { + Query.Include(x => x.Attendees); + } + } +} diff --git a/PracticeCalendar.Domain/Events/AttendeeAddedEvent.cs b/PracticeCalendar.Domain/Events/AttendeeAddedEvent.cs new file mode 100644 index 0000000..595718d --- /dev/null +++ b/PracticeCalendar.Domain/Events/AttendeeAddedEvent.cs @@ -0,0 +1,17 @@ +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Domain.Events +{ + public sealed class AttendeeAddedEvent : DomainEventBase + { + public AttendeeAddedEvent(PracticeEvent eventAggregate, Attendee addedAtendee) + { + EventAggregate = eventAggregate; + AddedAtendee = addedAtendee; + } + + public PracticeEvent EventAggregate { get; } + public Attendee AddedAtendee { get; set; } + } +} diff --git a/PracticeCalendar.Domain/Exceptions/InvalidAttendeeException.cs b/PracticeCalendar.Domain/Exceptions/InvalidAttendeeException.cs new file mode 100644 index 0000000..ac9317f --- /dev/null +++ b/PracticeCalendar.Domain/Exceptions/InvalidAttendeeException.cs @@ -0,0 +1,16 @@ +using PracticeCalendar.Domain.Common; + +namespace PracticeCalendar.Domain.Exceptions +{ + public class InvalidAttendeeException : DomainException + { + private int attendeeId; + + public InvalidAttendeeException(int attendeeId) + { + this.attendeeId = attendeeId; + } + + public override string Message => $"Invalid Attendee with id: {attendeeId}"; + } +} diff --git a/PracticeCalendar.Domain/Interfaces/IEmailSender.cs b/PracticeCalendar.Domain/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..6313918 --- /dev/null +++ b/PracticeCalendar.Domain/Interfaces/IEmailSender.cs @@ -0,0 +1,7 @@ +namespace PracticeCalendar.Domain.Interfaces +{ + public interface IEmailSender + { + Task SendEmailAsync(string to, string from, string subject, string body); + } +} diff --git a/PracticeCalendar.Domain/PracticeCalendar.Domain.csproj b/PracticeCalendar.Domain/PracticeCalendar.Domain.csproj new file mode 100644 index 0000000..778e558 --- /dev/null +++ b/PracticeCalendar.Domain/PracticeCalendar.Domain.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + diff --git a/PracticeCalendar.Infrastructure/InfrastructureDI.cs b/PracticeCalendar.Infrastructure/InfrastructureDI.cs new file mode 100644 index 0000000..15cf5f3 --- /dev/null +++ b/PracticeCalendar.Infrastructure/InfrastructureDI.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Interfaces; +using PracticeCalendar.Infrastructure.Notification; +using PracticeCalendar.Infrastructure.Persistence; +using System; + +namespace PracticeCalendar.Infrastructure +{ + public static class InfrastructureDI + { + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfigurationRoot configuration) + { + string connectionString = configuration.GetConnectionString("SqliteConnection"); + + services.AddDbContext(connectionString); + services.AddTransient(); + + services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); + + return services; + } + + public static void AddDbContext(this IServiceCollection services, string connectionString) => + services.AddDbContext(options => + options.UseSqlite(connectionString)); + } +} diff --git a/PracticeCalendar.Infrastructure/Notification/FileEmailSender.cs b/PracticeCalendar.Infrastructure/Notification/FileEmailSender.cs new file mode 100644 index 0000000..72763c3 --- /dev/null +++ b/PracticeCalendar.Infrastructure/Notification/FileEmailSender.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; +using PracticeCalendar.Domain.Interfaces; + +namespace PracticeCalendar.Infrastructure.Notification +{ + public class FileEmailSender : IEmailSender + { + private readonly ILogger logger; + + public FileEmailSender(ILogger logger) + { + this.logger = logger; + } + public Task SendEmailAsync(string to, string from, string subject, string body) + { + logger.LogDebug($"Sending email from {from}, to {to}, subject {subject}, body {body}"); + //TODO save email content to a file + return Task.CompletedTask; + } + } +} diff --git a/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs b/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs new file mode 100644 index 0000000..dca7d7f --- /dev/null +++ b/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using PracticeCalendar.Domain.Entities; +using System.Reflection; + +namespace PracticeCalendar.Infrastructure.Persistence +{ + public class ApplicationDbContext : DbContext + { + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } + + public DbSet Atendees => Set(); + public DbSet PracticeEvents => Set(); + } +} diff --git a/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContextInitialiser.cs b/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContextInitialiser.cs new file mode 100644 index 0000000..2a9c100 --- /dev/null +++ b/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContextInitialiser.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PracticeCalendar.Domain.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PracticeCalendar.Infrastructure.Persistence +{ + public class ApplicationDbContextInitialiser + { + private readonly ILogger logger; + private readonly ApplicationDbContext context; + + public ApplicationDbContextInitialiser(ILogger logger, + ApplicationDbContext context) + { + this.logger = logger; + this.context = context; + } + + public async Task InitialiseAsync() + { + try + { + if (context.Database.IsSqlite()) + { + await context.Database.MigrateAsync(); + } + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while initialising the database."); + throw; + } + } + + public async Task SeedAsync() + { + try + { + await TrySeedAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while seeding the database."); + throw; + } + } + + public async Task TrySeedAsync() + { + // Default data + // Seed, if necessary + if (!context.PracticeEvents.Any()) + { + context.PracticeEvents.Add(new PracticeEvent("Event 1", "Event 1 desc")); + + await context.SaveChangesAsync(); + } + } + } +} diff --git a/PracticeCalendar.Infrastructure/Persistence/Configuration/AttendeeConfiguration.cs b/PracticeCalendar.Infrastructure/Persistence/Configuration/AttendeeConfiguration.cs new file mode 100644 index 0000000..65ea3bf --- /dev/null +++ b/PracticeCalendar.Infrastructure/Persistence/Configuration/AttendeeConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Infrastructure.Persistence.Configuration +{ + public class AttendeeConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name) + .HasMaxLength(100) + .IsRequired(); + builder.Property(x => x.EmailAddress) + .HasMaxLength(100) + .IsRequired(); + } + } +} diff --git a/PracticeCalendar.Infrastructure/Persistence/Configuration/PracticeEventConfiguration.cs b/PracticeCalendar.Infrastructure/Persistence/Configuration/PracticeEventConfiguration.cs new file mode 100644 index 0000000..62aad1a --- /dev/null +++ b/PracticeCalendar.Infrastructure/Persistence/Configuration/PracticeEventConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Infrastructure.Persistence.Configuration +{ + public class PracticeEventConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Title) + .HasMaxLength(120) + .IsRequired(); + builder.Property(x => x.Description) + .HasMaxLength(1000) + .IsRequired(); + } + } +} diff --git a/PracticeCalendar.Infrastructure/Persistence/EfRepository.cs b/PracticeCalendar.Infrastructure/Persistence/EfRepository.cs new file mode 100644 index 0000000..901f9bc --- /dev/null +++ b/PracticeCalendar.Infrastructure/Persistence/EfRepository.cs @@ -0,0 +1,12 @@ +using Ardalis.Specification.EntityFrameworkCore; +using PracticeCalendar.Domain.Common.Interfaces; + +namespace PracticeCalendar.Infrastructure.Persistence +{ + public class EfRepository : RepositoryBase, IRepository where T : class, IAggregateRoot + { + public EfRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + } +} diff --git a/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj b/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj new file mode 100644 index 0000000..09d0693 --- /dev/null +++ b/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + diff --git a/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj b/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj new file mode 100644 index 0000000..ff139c1 --- /dev/null +++ b/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/PracticeCalendar.UnitTests/PracticeEventTest.cs b/PracticeCalendar.UnitTests/PracticeEventTest.cs new file mode 100644 index 0000000..5c064de --- /dev/null +++ b/PracticeCalendar.UnitTests/PracticeEventTest.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.UnitTests +{ + public class PracticeEventTest + { + string _eventTitle = "Event1"; + string _eventDescription = "Description"; + + string _attendeeName = "Claudiu Farcas"; + string _atendeeEmail = "claudiu.farcas@testingbee.com"; + + [Fact] + public void InitializeProperties() + { + var practiceEvent = new PracticeEvent(_eventTitle, _eventDescription); + practiceEvent.Title.Should().Be(_eventTitle); + practiceEvent.Description.Should().Be(_eventDescription); + practiceEvent.Attendees.Should().HaveCount(0); + } + + [Fact] + public void InitializeWithNullShouldThrowException() + { + Action act = () => { + var practiceEvent = new PracticeEvent(null, _eventDescription); + }; + act.Should().Throw(); + + act = () => { + var practiceEvent = new PracticeEvent(_eventTitle, null); + }; + act.Should().Throw(); + + act = () => { + var practiceEvent = new PracticeEvent(string.Empty, _eventDescription); + }; + act.Should().Throw(); + + act = () => { + var practiceEvent = new PracticeEvent(_eventTitle, string.Empty); + }; + act.Should().Throw(); + } + + [Fact] + public void AddAttendeeToEvent() + { + var practiceEvent = new PracticeEvent(_eventTitle, _eventDescription); + var testAttendee = new Attendee(_attendeeName, _atendeeEmail); + practiceEvent.AddAttendee(testAttendee); + practiceEvent.Attendees.Should().HaveCount(1); + } + } +} \ No newline at end of file diff --git a/PracticeCalendar.UnitTests/Usings.cs b/PracticeCalendar.UnitTests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/PracticeCalendar.UnitTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/PracticeCalendar.sln b/PracticeCalendar.sln new file mode 100644 index 0000000..3598091 --- /dev/null +++ b/PracticeCalendar.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32922.545 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PracticeCalendar.API", "PracticeCalendar.Api\PracticeCalendar.API.csproj", "{4B183BF7-16D7-4DA8-B80A-E08DB2CBC827}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PracticeCalendar.Domain", "PracticeCalendar.Domain\PracticeCalendar.Domain.csproj", "{002B8118-8B5A-4CF3-A29D-12A06803221B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PracticeCalendar.Infrastructure", "PracticeCalendar.Infrastructure\PracticeCalendar.Infrastructure.csproj", "{211BEF2A-5FB1-4F55-84FB-88FEF90A8316}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PracticeCalendar.UnitTests", "PracticeCalendar.UnitTests\PracticeCalendar.UnitTests.csproj", "{74849455-5E08-43FE-A718-0872DE7BC350}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5F2B7855-F03D-48C9-8733-FF1E077F18F5}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4B183BF7-16D7-4DA8-B80A-E08DB2CBC827}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B183BF7-16D7-4DA8-B80A-E08DB2CBC827}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B183BF7-16D7-4DA8-B80A-E08DB2CBC827}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B183BF7-16D7-4DA8-B80A-E08DB2CBC827}.Release|Any CPU.Build.0 = Release|Any CPU + {002B8118-8B5A-4CF3-A29D-12A06803221B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {002B8118-8B5A-4CF3-A29D-12A06803221B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {002B8118-8B5A-4CF3-A29D-12A06803221B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {002B8118-8B5A-4CF3-A29D-12A06803221B}.Release|Any CPU.Build.0 = Release|Any CPU + {211BEF2A-5FB1-4F55-84FB-88FEF90A8316}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {211BEF2A-5FB1-4F55-84FB-88FEF90A8316}.Debug|Any CPU.Build.0 = Debug|Any CPU + {211BEF2A-5FB1-4F55-84FB-88FEF90A8316}.Release|Any CPU.ActiveCfg = Release|Any CPU + {211BEF2A-5FB1-4F55-84FB-88FEF90A8316}.Release|Any CPU.Build.0 = Release|Any CPU + {74849455-5E08-43FE-A718-0872DE7BC350}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74849455-5E08-43FE-A718-0872DE7BC350}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74849455-5E08-43FE-A718-0872DE7BC350}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74849455-5E08-43FE-A718-0872DE7BC350}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D24E61FE-EF01-414B-B09C-1B5F9575BD3C} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..38eaeab --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Practice Calendar Api project + +## Projects +- API - (REST) webservice endpoint with OpenAPI capabilities (swagger) +- Domain - containd core elements of the domain (entities, domain events, interfaces) +- Infrastructure - plugged dependencies like persitence, email, files operations +- UnitTests - for aggregate and application domain testing + +## Running unit tests + +```sh +dotnet test +``` + +## Running the api service + +```sh + dotnet run --project .\PracticeCalendar.Api\PracticeCalendar.API.csproj +```