diff --git a/PracticeCalendar.Api/Controllers/ApiControllerBase.cs b/PracticeCalendar.Api/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000..6121624 --- /dev/null +++ b/PracticeCalendar.Api/Controllers/ApiControllerBase.cs @@ -0,0 +1,14 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace PracticeCalendar.API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ApiControllerBase : ControllerBase + { + private ISender _mediator = null!; + + protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); + } +} diff --git a/PracticeCalendar.Api/Controllers/EventsController.cs b/PracticeCalendar.Api/Controllers/EventsController.cs index 506dde0..a521e5c 100644 --- a/PracticeCalendar.Api/Controllers/EventsController.cs +++ b/PracticeCalendar.Api/Controllers/EventsController.cs @@ -1,106 +1,61 @@ -using AutoMapper; using Microsoft.AspNetCore.Mvc; -using PracticeCalendar.API.Model; -using PracticeCalendar.Domain.Common.Interfaces; -using PracticeCalendar.Domain.Entities; -using PracticeCalendar.Domain.Entities.Specifications; +using PracticeCalendar.API.Controllers; +using PracticeCalendar.Application.PracticeEvents.Commands; +using PracticeCalendar.Application.PracticeEvents.Queries; +using PracticeCalendar.Application.PracticeEvents.Queries.GetPracticeEvents; namespace PrcaticeCalendar.Controllers { - [ApiController] - [Route("api/[controller]")] - public class EventsController : ControllerBase + public class EventsController : ApiControllerBase { - private readonly IRepository eventsRepo; - private readonly IMapper mapper; - private readonly ILogger _logger; + private readonly ILogger logger; - public EventsController(IRepository eventsRepo, - IMapper mapper, - ILogger logger) + public EventsController(ILogger logger) { - this.eventsRepo = eventsRepo; - this.mapper = mapper; - _logger = logger; + this.logger = logger; } [HttpGet(Name = "GetAll")] - public async Task>> Get() + 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; + return await Mediator.Send(new GetPracticeEventsQuery()); } [HttpPost(Name = "Create practice event")] - public async Task CreateEvent(EventModel eventModel) + public async Task> CreateEvent(PracticeEventDto 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)); + return await Mediator.Send(new CreatePracticeEventCommand(eventModel)); } [HttpPut(Name = "Update practice event")] - public async Task UpdateEvent(EventModel eventModel) + public async Task> UpdateEvent(PracticeEventDto 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)); + return await Mediator.Send(new UpdatePracticeEventCommand(eventModel)); } [HttpDelete(Name = "Delete practice event")] - public async Task DeleteEvent(int practiceEventId) + public async Task DeleteEvent(int practiceEventId) { - var org = await eventsRepo.GetByIdAsync(practiceEventId); - await eventsRepo.DeleteAsync(org); - await eventsRepo.SaveChangesAsync(); + await Mediator.Send(new DeletePracticeEventCommand(practiceEventId)); + return Ok(); } [HttpPost] [Route("accept/{eventId}/{attendeeId}")] - public async Task AttendeeAcceptEvent(int eventId, int 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(); + await Mediator.Send(new AttendeeAcceptEventCommand(eventId, attendeeId)); + return Ok(); } [HttpPost] [Route("decline/{eventId}/{attendeeId}")] - public async Task AttendeeDeclineEvent(int eventId, int 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(); + await Mediator.Send(new AttendeeDeclineEventCommand(eventId, attendeeId)); + return Ok(); } } diff --git a/PracticeCalendar.Api/Model/EventModel.cs b/PracticeCalendar.Api/Model/EventModel.cs deleted file mode 100644 index 8738d4a..0000000 --- a/PracticeCalendar.Api/Model/EventModel.cs +++ /dev/null @@ -1,34 +0,0 @@ -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 index d9e4b7b..40f69c1 100644 --- a/PracticeCalendar.Api/PracticeCalendar.API.csproj +++ b/PracticeCalendar.Api/PracticeCalendar.API.csproj @@ -9,8 +9,8 @@ - + @@ -19,4 +19,8 @@ + + + + diff --git a/PracticeCalendar.Api/Program.cs b/PracticeCalendar.Api/Program.cs index 773fe32..fae36e4 100644 --- a/PracticeCalendar.Api/Program.cs +++ b/PracticeCalendar.Api/Program.cs @@ -1,5 +1,6 @@ using Hellang.Middleware.ProblemDetails; using Microsoft.AspNetCore.Hosting; +using PracticeCalendar.Application; using PracticeCalendar.Infrastructure; using PracticeCalendar.Infrastructure.Persistence; using System; @@ -16,14 +17,16 @@ namespace PracticeCalendar 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 application + builder.Services.AddApplicationServices(); //inject infrastructure - builder.Services.AddInfrastructure(builder.Configuration); + builder.Services.AddInfrastructureServices(builder.Configuration); + var app = builder.Build(); diff --git a/PracticeCalendar.Api/appsettings.Development.json b/PracticeCalendar.Api/appsettings.Development.json index 0c208ae..a6e86ac 100644 --- a/PracticeCalendar.Api/appsettings.Development.json +++ b/PracticeCalendar.Api/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Warning" } } diff --git a/PracticeCalendar.Api/appsettings.json b/PracticeCalendar.Api/appsettings.json index 83a9a57..9464942 100644 --- a/PracticeCalendar.Api/appsettings.json +++ b/PracticeCalendar.Api/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Warning" } }, diff --git a/PracticeCalendar.Api/practicecalendar.sqlite b/PracticeCalendar.Api/practicecalendar.sqlite index b7646ed..951caee 100644 Binary files a/PracticeCalendar.Api/practicecalendar.sqlite and b/PracticeCalendar.Api/practicecalendar.sqlite differ diff --git a/PracticeCalendar.Application/ConfigureServices.cs b/PracticeCalendar.Application/ConfigureServices.cs new file mode 100644 index 0000000..8f0013d --- /dev/null +++ b/PracticeCalendar.Application/ConfigureServices.cs @@ -0,0 +1,18 @@ +using MapsterMapper; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace PracticeCalendar.Application +{ + public static class ConfigureServices + { + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddSingleton(new Mapper(new Mapster.TypeAdapterConfig())); + services.AddMediatR(Assembly.GetExecutingAssembly()); + + return services; + } + } +} diff --git a/PracticeCalendar.Application/PracticeCalendar.Application.csproj b/PracticeCalendar.Application/PracticeCalendar.Application.csproj new file mode 100644 index 0000000..a8ccadf --- /dev/null +++ b/PracticeCalendar.Application/PracticeCalendar.Application.csproj @@ -0,0 +1,20 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + + diff --git a/PracticeCalendar.Application/PracticeEvents/Commands/AttendeeAcceptEventCommand.cs b/PracticeCalendar.Application/PracticeEvents/Commands/AttendeeAcceptEventCommand.cs new file mode 100644 index 0000000..0461478 --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Commands/AttendeeAcceptEventCommand.cs @@ -0,0 +1,44 @@ +using MediatR; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; +using PracticeCalendar.Domain.Entities.Specifications; +using PracticeCalendar.Domain.Exceptions; + +namespace PracticeCalendar.Application.PracticeEvents.Commands +{ + public record AttendeeAcceptEventCommand : IRequest + { + public AttendeeAcceptEventCommand(int eventId, int attendeeId) + { + EventId = eventId; + AttendeeId = attendeeId; + } + + public int EventId { get; } + public int AttendeeId { get; } + } + + public class AttendeeAcceptEventCommandHandler : IRequestHandler + { + private readonly IRepository eventsRepo; + + public AttendeeAcceptEventCommandHandler(IRepository eventsRepo) + { + this.eventsRepo = eventsRepo; + } + + public async Task Handle(AttendeeAcceptEventCommand request, CancellationToken cancellationToken) + { + var spec = new PracticeEventByIdWithAttendees(request.EventId); + var practiceEvent = await eventsRepo.FirstOrDefaultAsync(spec, cancellationToken); + if (practiceEvent == null) + { + throw new PracticeEventNotFoundException(); + } + practiceEvent.AttendeeAcceptEvent(request.AttendeeId); + await eventsRepo.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Commands/AttendeeDeclineEventCommand.cs b/PracticeCalendar.Application/PracticeEvents/Commands/AttendeeDeclineEventCommand.cs new file mode 100644 index 0000000..a7d1908 --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Commands/AttendeeDeclineEventCommand.cs @@ -0,0 +1,43 @@ +using MediatR; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; +using PracticeCalendar.Domain.Entities.Specifications; +using PracticeCalendar.Domain.Exceptions; + +namespace PracticeCalendar.Application.PracticeEvents.Commands +{ + public record AttendeeDeclineEventCommand : IRequest + { + public AttendeeDeclineEventCommand(int eventId, int attendeeId) + { + EventId = eventId; + AttendeeId = attendeeId; + } + + public int EventId { get; } + public int AttendeeId { get; } + } + + public class AttendeeDeclineEventCommandHandler : IRequestHandler + { + private readonly IRepository eventsRepo; + + public AttendeeDeclineEventCommandHandler(IRepository eventsRepo) + { + this.eventsRepo = eventsRepo; + } + + public async Task Handle(AttendeeDeclineEventCommand request, CancellationToken cancellationToken) + { + var spec = new PracticeEventByIdWithAttendees(request.EventId); + var practiceEvent = await eventsRepo.FirstOrDefaultAsync(spec, cancellationToken); + if (practiceEvent == null) + { + throw new PracticeEventNotFoundException(); + } + practiceEvent.AttendeeDeclineEvent(request.AttendeeId); + + return Unit.Value; + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Commands/CreatePracticeEventCommand.cs b/PracticeCalendar.Application/PracticeEvents/Commands/CreatePracticeEventCommand.cs new file mode 100644 index 0000000..81ea633 --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Commands/CreatePracticeEventCommand.cs @@ -0,0 +1,41 @@ +using Mapster; +using MediatR; +using PracticeCalendar.Application.PracticeEvents.Queries; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Application.PracticeEvents.Commands +{ + public record CreatePracticeEventCommand : IRequest + { + public CreatePracticeEventCommand(PracticeEventDto eventDto) + { + Event = eventDto; + } + + public PracticeEventDto Event { get; } + } + + public class CreatePracticeEventCommandHandler : IRequestHandler + { + private readonly IRepository eventsRepo; + + public CreatePracticeEventCommandHandler(IRepository eventsRepo) + { + this.eventsRepo = eventsRepo; + } + + public async Task Handle(CreatePracticeEventCommand request, CancellationToken cancellationToken) + { + var input = request.Event; + var practiceEvent = new PracticeEvent(input.Title, input.Description); + foreach (var att in input.Attendees) + { + practiceEvent.AddAttendee(new Attendee(att.Name, att.EmailAddress)); + } + var result = await eventsRepo.AddAsync(practiceEvent, cancellationToken); + await eventsRepo.SaveChangesAsync(cancellationToken); + return result.Adapt(); + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Commands/DeletePracticeEventCommand.cs b/PracticeCalendar.Application/PracticeEvents/Commands/DeletePracticeEventCommand.cs new file mode 100644 index 0000000..fe82131 --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Commands/DeletePracticeEventCommand.cs @@ -0,0 +1,39 @@ +using MediatR; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; +using PracticeCalendar.Domain.Exceptions; + +namespace PracticeCalendar.Application.PracticeEvents.Commands +{ + public record DeletePracticeEventCommand : IRequest + { + public DeletePracticeEventCommand(int practiceEventId) + { + PracticeEventId = practiceEventId; + } + + public int PracticeEventId { get; } + } + public class DeletePracticeEventCommandHandler : IRequestHandler + { + private readonly IRepository eventsRepo; + + public DeletePracticeEventCommandHandler(IRepository eventsRepo) + { + this.eventsRepo = eventsRepo; + } + + public async Task Handle(DeletePracticeEventCommand request, CancellationToken cancellationToken) + { + var org = await eventsRepo.GetByIdAsync(request.PracticeEventId, cancellationToken); + if (org == null) + { + throw new PracticeEventNotFoundException(); + } + await eventsRepo.DeleteAsync(org, cancellationToken); + await eventsRepo.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Commands/UpdatePracticeEventCommand.cs b/PracticeCalendar.Application/PracticeEvents/Commands/UpdatePracticeEventCommand.cs new file mode 100644 index 0000000..d829ff8 --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Commands/UpdatePracticeEventCommand.cs @@ -0,0 +1,43 @@ +using Mapster; +using MediatR; +using PracticeCalendar.Application.PracticeEvents.Queries; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; +using PracticeCalendar.Domain.Exceptions; + +namespace PracticeCalendar.Application.PracticeEvents.Commands +{ + public record UpdatePracticeEventCommand : IRequest + { + public UpdatePracticeEventCommand(PracticeEventDto eventDto) + { + Event = eventDto; + } + + public PracticeEventDto Event { get; } + } + + public class UpdatePracticeEventCommandHandler : IRequestHandler + { + private readonly IRepository eventsRepo; + + public UpdatePracticeEventCommandHandler(IRepository eventsRepo) + { + this.eventsRepo = eventsRepo; + } + + public async Task Handle(UpdatePracticeEventCommand request, CancellationToken cancellationToken) + { + var eventModel = request.Event; + var practiceEvent = await eventsRepo.GetByIdAsync(eventModel.Id, cancellationToken); + if (practiceEvent == null) + { + throw new PracticeEventNotFoundException(); + } + practiceEvent.UpdateTitleAndDescription(eventModel.Title, eventModel.Description); + await eventsRepo.UpdateAsync(practiceEvent, cancellationToken); + await eventsRepo.SaveChangesAsync(cancellationToken); + return practiceEvent.Adapt(); + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Events/AttendeeAddedEventNotification.cs b/PracticeCalendar.Application/PracticeEvents/Events/AttendeeAddedEventNotification.cs new file mode 100644 index 0000000..e736f4e --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Events/AttendeeAddedEventNotification.cs @@ -0,0 +1,24 @@ +using MediatR; +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Events; +using PracticeCalendar.Domain.Interfaces; + +namespace PracticeCalendar.Application.PracticeEvents.Events +{ + public class AttendeeAddedEventNotification : INotificationHandler> + { + private readonly IEmailSender emailSender; + + public AttendeeAddedEventNotification(IEmailSender emailSender) + { + this.emailSender = emailSender; + } + + public async Task Handle(DomainEventNotification notification, CancellationToken cancellationToken) + { + var sendTo = notification.DomainEvent.AddedAtendee.EmailAddress; + await emailSender.SendEmailAsync(sendTo, "system", + "You have been added to the event", "Confirmed you have been added to the event."); + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Queries/AttendeeDto.cs b/PracticeCalendar.Application/PracticeEvents/Queries/AttendeeDto.cs new file mode 100644 index 0000000..3795e46 --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Queries/AttendeeDto.cs @@ -0,0 +1,10 @@ +namespace PracticeCalendar.Application.PracticeEvents.Queries +{ + public class AttendeeDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + public bool IsAttending { get; set; } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Queries/GetPracticeEvents/GetPracticeEventsQuery.cs b/PracticeCalendar.Application/PracticeEvents/Queries/GetPracticeEvents/GetPracticeEventsQuery.cs new file mode 100644 index 0000000..f062e6b --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Queries/GetPracticeEvents/GetPracticeEventsQuery.cs @@ -0,0 +1,38 @@ +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.Extensions.Logging; +using PracticeCalendar.Domain.Common.Interfaces; +using PracticeCalendar.Domain.Entities; +using PracticeCalendar.Domain.Entities.Specifications; + +namespace PracticeCalendar.Application.PracticeEvents.Queries.GetPracticeEvents +{ + public record class GetPracticeEventsQuery : IRequest> + { + } + + public class GetPracticeEventsQueryHandler : IRequestHandler> + { + private readonly ILogger logger; + private readonly IRepository eventsRepo; + private readonly IMapper mapper; + + public GetPracticeEventsQueryHandler(IRepository eventsRepo, + ILogger logger, + IMapper mapper) + { + this.eventsRepo = eventsRepo; + this.logger = logger; + this.mapper = mapper; + } + + public async Task> Handle(GetPracticeEventsQuery request, CancellationToken cancellationToken) + { + var spec = new PracticeEventsWithAttendees(); + var evList = await eventsRepo.ListAsync(spec, cancellationToken); + var lst = evList.Adapt>(mapper.Config); + return lst; + } + } +} diff --git a/PracticeCalendar.Application/PracticeEvents/Queries/PracticeEventDto.cs b/PracticeCalendar.Application/PracticeEvents/Queries/PracticeEventDto.cs new file mode 100644 index 0000000..434b63e --- /dev/null +++ b/PracticeCalendar.Application/PracticeEvents/Queries/PracticeEventDto.cs @@ -0,0 +1,14 @@ +namespace PracticeCalendar.Application.PracticeEvents.Queries +{ + public class PracticeEventDto + { + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + + public IList Attendees { get; set; } = new List(); + } +} diff --git a/PracticeCalendar.Domain/Common/DomainEventBase.cs b/PracticeCalendar.Domain/Common/DomainEventBase.cs index 3ee56bc..a6a6a07 100644 --- a/PracticeCalendar.Domain/Common/DomainEventBase.cs +++ b/PracticeCalendar.Domain/Common/DomainEventBase.cs @@ -3,5 +3,6 @@ public class DomainEventBase { public DateTime EventDate { get; protected set; } = DateTime.UtcNow; + public bool IsPublished { get; set; } } } diff --git a/PracticeCalendar.Domain/Common/DomainEventNotification.cs b/PracticeCalendar.Domain/Common/DomainEventNotification.cs new file mode 100644 index 0000000..1281805 --- /dev/null +++ b/PracticeCalendar.Domain/Common/DomainEventNotification.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace PracticeCalendar.Domain.Common +{ + public class DomainEventNotification : INotification where TDomainEvent : DomainEventBase + { + public TDomainEvent DomainEvent { get; } + + public DomainEventNotification(TDomainEvent domainEvent) + { + DomainEvent = domainEvent; + } + } +} diff --git a/PracticeCalendar.Domain/Common/Interfaces/IDomainEventService.cs b/PracticeCalendar.Domain/Common/Interfaces/IDomainEventService.cs new file mode 100644 index 0000000..e7884fc --- /dev/null +++ b/PracticeCalendar.Domain/Common/Interfaces/IDomainEventService.cs @@ -0,0 +1,7 @@ +namespace PracticeCalendar.Domain.Common.Interfaces +{ + public interface IDomainEventService + { + Task Publish(DomainEventBase domainEvent); + } +} diff --git a/PracticeCalendar.Domain/Entities/Attendee.cs b/PracticeCalendar.Domain/Entities/Attendee.cs index d376b21..8a78a53 100644 --- a/PracticeCalendar.Domain/Entities/Attendee.cs +++ b/PracticeCalendar.Domain/Entities/Attendee.cs @@ -1,6 +1,5 @@ using Ardalis.GuardClauses; using PracticeCalendar.Domain.Common; -using System.Diagnostics.Contracts; namespace PracticeCalendar.Domain.Entities { diff --git a/PracticeCalendar.Domain/Entities/PracticeEvent.cs b/PracticeCalendar.Domain/Entities/PracticeEvent.cs index 5da71ad..4f823dd 100644 --- a/PracticeCalendar.Domain/Entities/PracticeEvent.cs +++ b/PracticeCalendar.Domain/Entities/PracticeEvent.cs @@ -22,8 +22,7 @@ namespace PracticeCalendar.Domain.Entities 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 IList Attendees { get; private set; } = new List(); public DateTime StartTime { get; private set; } public DateTime EndTime { get; private set; } @@ -32,7 +31,7 @@ namespace PracticeCalendar.Domain.Entities { Guard.Against.Null(attendee, nameof(attendee)); attendee.AssignToEvent(this.Id); - attendees.Add(attendee); + Attendees.Add(attendee); var attendeeAddedEvent = new AttendeeAddedEvent(this, attendee); base.RegisterDomainEvent(attendeeAddedEvent); @@ -42,6 +41,9 @@ namespace PracticeCalendar.Domain.Entities { this.Title = title; this.Description = description; + + var titleDescUpdatedEvent = new EventUpdateTitleAndDescriptionEvent(this); + base.RegisterDomainEvent(titleDescUpdatedEvent); } public void AttendeeAcceptEvent(int attendeeId) @@ -50,6 +52,9 @@ namespace PracticeCalendar.Domain.Entities if (attendee == null) throw new InvalidAttendeeException(attendeeId); attendee.SetIsAttending(true); + + var attendeeAcceptedEvent = new AttendeeAcceptedEvent(this, attendee); + base.RegisterDomainEvent(attendeeAcceptedEvent); } public void AttendeeDeclineEvent(int attendeeId) @@ -58,6 +63,9 @@ namespace PracticeCalendar.Domain.Entities if (attendee == null) throw new InvalidAttendeeException(attendeeId); attendee.SetIsAttending(false); + + var attendeeDeclinedEvent = new AttendeeDeclinedEvent(this, attendee); + base.RegisterDomainEvent(attendeeDeclinedEvent); } } } diff --git a/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs b/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs index 11ded62..8490ab5 100644 --- a/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs +++ b/PracticeCalendar.Domain/Entities/Specifications/PracticeEventsWithAttendees.cs @@ -6,7 +6,8 @@ namespace PracticeCalendar.Domain.Entities.Specifications { public PracticeEventsWithAttendees() { - Query.Include(x => x.Attendees); + Query.AsNoTracking() + .Include(x => x.Attendees); } } } diff --git a/PracticeCalendar.Domain/Events/AttendeeAcceptEvent.cs b/PracticeCalendar.Domain/Events/AttendeeAcceptEvent.cs new file mode 100644 index 0000000..a367c8d --- /dev/null +++ b/PracticeCalendar.Domain/Events/AttendeeAcceptEvent.cs @@ -0,0 +1,17 @@ +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Domain.Events +{ + public sealed class AttendeeAcceptedEvent : DomainEventBase + { + public AttendeeAcceptedEvent(PracticeEvent practiceEvent, Attendee attendee) + { + PracticeEvent = practiceEvent; + Attendee = attendee; + } + + public PracticeEvent PracticeEvent { get; } + public Attendee Attendee { get; } + } +} diff --git a/PracticeCalendar.Domain/Events/AttendeeDeclinedEvent.cs b/PracticeCalendar.Domain/Events/AttendeeDeclinedEvent.cs new file mode 100644 index 0000000..d66cec2 --- /dev/null +++ b/PracticeCalendar.Domain/Events/AttendeeDeclinedEvent.cs @@ -0,0 +1,17 @@ +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Domain.Events +{ + public sealed class AttendeeDeclinedEvent : DomainEventBase + { + public AttendeeDeclinedEvent(PracticeEvent practiceEvent, Attendee attendee) + { + PracticeEvent = practiceEvent; + Attendee = attendee; + } + + public PracticeEvent PracticeEvent { get; } + public Attendee Attendee { get; } + } +} diff --git a/PracticeCalendar.Domain/Events/EventUpdateTitleAndDescriptionEvent.cs b/PracticeCalendar.Domain/Events/EventUpdateTitleAndDescriptionEvent.cs new file mode 100644 index 0000000..c7339c7 --- /dev/null +++ b/PracticeCalendar.Domain/Events/EventUpdateTitleAndDescriptionEvent.cs @@ -0,0 +1,15 @@ +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.Domain.Events +{ + public sealed class EventUpdateTitleAndDescriptionEvent : DomainEventBase + { + public EventUpdateTitleAndDescriptionEvent(PracticeEvent practiceEvent) + { + PracticeEvent = practiceEvent; + } + + public PracticeEvent PracticeEvent { get; } + } +} diff --git a/PracticeCalendar.Domain/Exceptions/PracticeEventNotFound.cs b/PracticeCalendar.Domain/Exceptions/PracticeEventNotFound.cs new file mode 100644 index 0000000..323d961 --- /dev/null +++ b/PracticeCalendar.Domain/Exceptions/PracticeEventNotFound.cs @@ -0,0 +1,8 @@ +using PracticeCalendar.Domain.Common; + +namespace PracticeCalendar.Domain.Exceptions +{ + public class PracticeEventNotFoundException : DomainException + { + } +} diff --git a/PracticeCalendar.Infrastructure/InfrastructureDI.cs b/PracticeCalendar.Infrastructure/ConfigureServices.cs similarity index 75% rename from PracticeCalendar.Infrastructure/InfrastructureDI.cs rename to PracticeCalendar.Infrastructure/ConfigureServices.cs index 15cf5f3..a01c34a 100644 --- a/PracticeCalendar.Infrastructure/InfrastructureDI.cs +++ b/PracticeCalendar.Infrastructure/ConfigureServices.cs @@ -5,18 +5,20 @@ using PracticeCalendar.Domain.Common.Interfaces; using PracticeCalendar.Domain.Interfaces; using PracticeCalendar.Infrastructure.Notification; using PracticeCalendar.Infrastructure.Persistence; -using System; +using PracticeCalendar.Infrastructure.Services; namespace PracticeCalendar.Infrastructure { - public static class InfrastructureDI + public static class ConfigureServices { - public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfigurationRoot configuration) + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, + IConfigurationRoot configuration) { string connectionString = configuration.GetConnectionString("SqliteConnection"); services.AddDbContext(connectionString); services.AddTransient(); + services.AddTransient(); services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); diff --git a/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs b/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs index dca7d7f..8962c25 100644 --- a/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/PracticeCalendar.Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Common.Interfaces; using PracticeCalendar.Domain.Entities; using System.Reflection; @@ -6,9 +8,15 @@ namespace PracticeCalendar.Infrastructure.Persistence { public class ApplicationDbContext : DbContext { - public ApplicationDbContext(DbContextOptions options) + private readonly IDomainEventService domainEventService; + + public DbSet Atendees => Set(); + public DbSet PracticeEvents => Set(); + + public ApplicationDbContext(DbContextOptions options, IDomainEventService domainEventService) : base(options) { + this.domainEventService = domainEventService; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -17,7 +25,30 @@ namespace PracticeCalendar.Infrastructure.Persistence modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); } - public DbSet Atendees => Set(); - public DbSet PracticeEvents => Set(); + + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) + { + var events = ChangeTracker.Entries() + .Select(x => x.Entity.DomainEvents) + .SelectMany(x => x) + .Where(domainEvent => !domainEvent.IsPublished) + .ToArray(); + + var result = await base.SaveChangesAsync(cancellationToken); + + await DispatchEvents(events); + + return result; + } + + private async Task DispatchEvents(DomainEventBase[] events) + { + foreach (var @event in events) + { + @event.IsPublished = true; + await domainEventService.Publish(@event); + } + } } } diff --git a/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj b/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj index 09d0693..02e0568 100644 --- a/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj +++ b/PracticeCalendar.Infrastructure/PracticeCalendar.Infrastructure.csproj @@ -1,4 +1,4 @@ - + net6.0 @@ -13,7 +13,7 @@ - + diff --git a/PracticeCalendar.Infrastructure/Services/DomainEventService.cs b/PracticeCalendar.Infrastructure/Services/DomainEventService.cs new file mode 100644 index 0000000..126f000 --- /dev/null +++ b/PracticeCalendar.Infrastructure/Services/DomainEventService.cs @@ -0,0 +1,30 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using PracticeCalendar.Domain.Common; +using PracticeCalendar.Domain.Common.Interfaces; + +namespace PracticeCalendar.Infrastructure.Services +{ + public class DomainEventService : IDomainEventService + { + private readonly ILogger logger; + private readonly IPublisher mediator; + + public DomainEventService(ILogger logger, IPublisher mediator) + { + this.logger = logger; + this.mediator = mediator; + } + public async Task Publish(DomainEventBase domainEvent) + { + logger.LogInformation("Publishing domain event. Event - {event}", domainEvent.GetType().Name); + await mediator.Publish(GetNotificationCorrespondingToDomainEvent(domainEvent)); + } + + private INotification GetNotificationCorrespondingToDomainEvent(DomainEventBase domainEvent) + { + return (INotification)Activator.CreateInstance( + typeof(DomainEventNotification<>).MakeGenericType(domainEvent.GetType()), domainEvent)!; + } + } +} diff --git a/PracticeCalendar.UnitTests/PracticeEventTest.cs b/PracticeCalendar.UnitTests/Domain/PracticeEventTest.cs similarity index 72% rename from PracticeCalendar.UnitTests/PracticeEventTest.cs rename to PracticeCalendar.UnitTests/Domain/PracticeEventTest.cs index 5c064de..15bc8a3 100644 --- a/PracticeCalendar.UnitTests/PracticeEventTest.cs +++ b/PracticeCalendar.UnitTests/Domain/PracticeEventTest.cs @@ -1,15 +1,14 @@ using FluentAssertions; using PracticeCalendar.Domain.Entities; -namespace PracticeCalendar.UnitTests +namespace PracticeCalendar.UnitTests.Domain { public class PracticeEventTest { - string _eventTitle = "Event1"; - string _eventDescription = "Description"; - - string _attendeeName = "Claudiu Farcas"; - string _atendeeEmail = "claudiu.farcas@testingbee.com"; + readonly string _eventTitle = "Event1"; + readonly string _eventDescription = "Description"; + readonly string _attendeeName = "Claudiu Farcas"; + readonly string _atendeeEmail = "claudiu.farcas@testingbee.com"; [Fact] public void InitializeProperties() @@ -23,22 +22,26 @@ namespace PracticeCalendar.UnitTests [Fact] public void InitializeWithNullShouldThrowException() { - Action act = () => { - var practiceEvent = new PracticeEvent(null, _eventDescription); + Action act = () => + { + var practiceEvent = new PracticeEvent(null!, _eventDescription); }; act.Should().Throw(); - act = () => { - var practiceEvent = new PracticeEvent(_eventTitle, null); + act = () => + { + var practiceEvent = new PracticeEvent(_eventTitle, null!); }; act.Should().Throw(); - act = () => { + act = () => + { var practiceEvent = new PracticeEvent(string.Empty, _eventDescription); }; act.Should().Throw(); - act = () => { + act = () => + { var practiceEvent = new PracticeEvent(_eventTitle, string.Empty); }; act.Should().Throw(); diff --git a/PracticeCalendar.UnitTests/Integration/BaseTest.cs b/PracticeCalendar.UnitTests/Integration/BaseTest.cs new file mode 100644 index 0000000..2419a06 --- /dev/null +++ b/PracticeCalendar.UnitTests/Integration/BaseTest.cs @@ -0,0 +1,12 @@ +using static PracticeCalendar.UnitTests.Integration.Testing; + +namespace PracticeCalendar.UnitTests.Integration +{ + public class BaseTest + { + public BaseTest() + { + ResetState(); + } + } +} diff --git a/PracticeCalendar.UnitTests/Integration/CustomWebApplicationFactory.cs b/PracticeCalendar.UnitTests/Integration/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..5130742 --- /dev/null +++ b/PracticeCalendar.UnitTests/Integration/CustomWebApplicationFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PracticeCalendar.Infrastructure.Persistence; + +namespace PracticeCalendar.UnitTests.Integration +{ + public class CustomWebApplicationFactory : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration(configurationBuilder => + { + var integrationConfig = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddEnvironmentVariables() + .Build(); + + configurationBuilder.AddConfiguration(integrationConfig); + }); + + builder.ConfigureServices((builder, services) => + { + services.Remove>(); + services.AddDbContext(options => + options.UseInMemoryDatabase("InMemoryDbForTesting") + ); + }); + } + } +} diff --git a/PracticeCalendar.UnitTests/Integration/PracticeEvents/CreatePracticeEventsTest.cs b/PracticeCalendar.UnitTests/Integration/PracticeEvents/CreatePracticeEventsTest.cs new file mode 100644 index 0000000..d8aaf2d --- /dev/null +++ b/PracticeCalendar.UnitTests/Integration/PracticeEvents/CreatePracticeEventsTest.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using PracticeCalendar.Application.PracticeEvents.Commands; +using PracticeCalendar.Application.PracticeEvents.Queries; + +using static PracticeCalendar.UnitTests.Integration.Testing; + +namespace PracticeCalendar.UnitTests.Integration.PracticeEvents +{ + public class CreatePracticeEventsTest : BaseTest + { + [Fact] + public async Task ShouldCreatePracticeEvent() + { + await RunBeforeAnyTests(); + + var query = new CreatePracticeEventCommand(new PracticeEventDto + { + Title = "Some title", + Description = "Some desc", + StartTime = DateTime.Now, + EndTime = DateTime.Now, + Attendees = { + new AttendeeDto + { + Name = "Claudiu F", + EmailAddress = "claudiuf@somewhere.com" + }, + new AttendeeDto + { + Name = "Claudiu F 2", + EmailAddress = "claudiuf2@somewhere.com" + } + } + }); + + var result = await SendAsync(query); + + result.Should().NotBeNull(); + result.Id.Should().NotBe(0); + result.Attendees.Count.Should().Be(2); + } + } +} diff --git a/PracticeCalendar.UnitTests/Integration/PracticeEvents/GetPracticeEventsTest.cs b/PracticeCalendar.UnitTests/Integration/PracticeEvents/GetPracticeEventsTest.cs new file mode 100644 index 0000000..586719a --- /dev/null +++ b/PracticeCalendar.UnitTests/Integration/PracticeEvents/GetPracticeEventsTest.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using PracticeCalendar.Application.PracticeEvents.Queries.GetPracticeEvents; +using PracticeCalendar.Domain.Entities; + +using static PracticeCalendar.UnitTests.Integration.Testing; + +namespace PracticeCalendar.UnitTests.Integration.PracticeEvents +{ + public class GetPracticeEventsTest : BaseTest + { + + [Fact] + public async Task ShouldReturnZeroResult() + { + await RunBeforeAnyTests(); + + var query = new GetPracticeEventsQuery(); + + var result = await SendAsync(query); + + result.Count.Should().Be(0); + } + + [Fact] + public async Task ShouldReturnAllListsAndItems() + { + await RunBeforeAnyTests(); + + await AddAsync(new PracticeEvent("Test Event", "Event description") + { + Id = 1, + Attendees = { + new Attendee("Claudiu F", "claudiuf@busybee.com") + { + Id = 1 + } + } + }); + + var query = new GetPracticeEventsQuery(); + + var result = await SendAsync(query); + + result.Should().HaveCount(1); + result.First().Attendees.Should().HaveCount(1); + } + } +} diff --git a/PracticeCalendar.UnitTests/Integration/ServiceCollectionExtensions.cs b/PracticeCalendar.UnitTests/Integration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..aa4848c --- /dev/null +++ b/PracticeCalendar.UnitTests/Integration/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace PracticeCalendar.UnitTests.Integration +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection Remove(this IServiceCollection services) + { + var serviceDescriptor = services.FirstOrDefault(d => + d.ServiceType == typeof(TService)); + + if (serviceDescriptor != null) + { + services.Remove(serviceDescriptor); + } + + return services; + } + } +} diff --git a/PracticeCalendar.UnitTests/Integration/Testing.cs b/PracticeCalendar.UnitTests/Integration/Testing.cs new file mode 100644 index 0000000..11c41c3 --- /dev/null +++ b/PracticeCalendar.UnitTests/Integration/Testing.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Configuration; +using MediatR; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using PracticeCalendar.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using PracticeCalendar.Domain.Entities; + +namespace PracticeCalendar.UnitTests.Integration +{ + public partial class Testing + { + private static WebApplicationFactory _factory = null!; + private static IConfiguration _configuration = null!; + private static IServiceScopeFactory _scopeFactory = null!; + + public static async Task RunBeforeAnyTests() + { + _factory = new CustomWebApplicationFactory(); + _scopeFactory = _factory.Services.GetRequiredService(); + _configuration = _factory.Services.GetRequiredService(); + } + + public static async Task SendAsync(IRequest request) + { + using var scope = _scopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + return await mediator.Send(request); + } + + public static async Task FindAsync(params object[] keyValues) + where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.FindAsync(keyValues); + } + + public static async Task AddAsync(TEntity entity) + where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + context.Add(entity); + + await context.SaveChangesAsync(); + } + + public static async Task CountAsync() where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.Set().CountAsync(); + } + + public static void ResetState() + { + if (_scopeFactory != null) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Set().RemoveRange(context.Set().ToList()); + context.Set().RemoveRange(context.Set().ToList()); + context.SaveChanges(); + } + } + } +} diff --git a/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj b/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj index ff139c1..00e6796 100644 --- a/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj +++ b/PracticeCalendar.UnitTests/PracticeCalendar.UnitTests.csproj @@ -8,9 +8,24 @@ false + + + + + + + PreserveNewest + true + PreserveNewest + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,7 +38,17 @@ - + + + + + + + + + + PreserveNewest + diff --git a/PracticeCalendar.UnitTests/appsettings.json b/PracticeCalendar.UnitTests/appsettings.json new file mode 100644 index 0000000..4ba8810 --- /dev/null +++ b/PracticeCalendar.UnitTests/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "UseInMemoryDatabase": true, //integration tests always with real database + "ConnectionStrings": { + "TestSqliteConnection": "Data Source=practicecalendar_test.sqlite" + } +} diff --git a/PracticeCalendar.UnitTests/xunit.runner.json b/PracticeCalendar.UnitTests/xunit.runner.json new file mode 100644 index 0000000..3ad9c00 --- /dev/null +++ b/PracticeCalendar.UnitTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} \ No newline at end of file diff --git a/PracticeCalendar.sln b/PracticeCalendar.sln index 3598091..4e6da5b 100644 --- a/PracticeCalendar.sln +++ b/PracticeCalendar.sln @@ -7,15 +7,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PracticeCalendar.API", "Pra 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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PracticeCalendar.Application", "PracticeCalendar.Application\PracticeCalendar.Application.csproj", "{094CA45E-92DD-47A5-A7EF-F867DB8B0625}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +40,10 @@ Global {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 + {094CA45E-92DD-47A5-A7EF-F867DB8B0625}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {094CA45E-92DD-47A5-A7EF-F867DB8B0625}.Debug|Any CPU.Build.0 = Debug|Any CPU + {094CA45E-92DD-47A5-A7EF-F867DB8B0625}.Release|Any CPU.ActiveCfg = Release|Any CPU + {094CA45E-92DD-47A5-A7EF-F867DB8B0625}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE