vote-for-link (#22)

This commit is contained in:
Julien Leicher 2020-12-10 18:02:26 +01:00
parent 2325522b98
commit 9650d6dc86
24 changed files with 410 additions and 65 deletions

View File

@ -4,7 +4,7 @@ using MediatR;
namespace HN.Application namespace HN.Application
{ {
public sealed class GetLinkQuery : IRequest<LinkDTO> public sealed class GetLinkQuery : IRequest<LinkDto>
{ {
[Required] [Required]
public Guid Id { get; set; } public Guid Id { get; set; }

View File

@ -2,10 +2,11 @@ using System.Threading;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using MediatR;
using HN.Domain;
namespace HN.Application namespace HN.Application
{ {
public sealed class GetLinkQueryHandler : IRequestHandler<GetLinkQuery, LinkDTO> public sealed class GetLinkQueryHandler : IRequestHandler<GetLinkQuery, LinkDto>
{ {
private readonly IDbContext _context; private readonly IDbContext _context;
@ -14,13 +15,20 @@ namespace HN.Application
_context = context; _context = context;
} }
public Task<LinkDTO> Handle(GetLinkQuery request, CancellationToken cancellationToken) public Task<LinkDto> Handle(GetLinkQuery request, CancellationToken cancellationToken)
{ {
var result = from link in _context.Links var result = from link in _context.Links
where link.Id == request.Id where link.Id == request.Id
select new LinkDTO(link); select new LinkDto
{
Id = link.Id,
Url = link.Url,
CreatedAt = link.CreatedAt,
UpVotes = link.Votes.Count(v => v.Type == VoteType.Up),
DownVotes = link.Votes.Count(v => v.Type == VoteType.Down)
};
return Task.FromResult(result.FirstOrDefault()); return Task.FromResult(result.SingleOrDefault());
} }
} }
} }

View File

@ -1,19 +1,18 @@
using System; using System;
using HN.Domain;
namespace HN.Application namespace HN.Application
{ {
public sealed class LinkDTO public sealed class LinkDto
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Url { get; set; } public string Url { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public int UpVotes { get; set; }
public int DownVotes { get; set; }
public LinkDTO(Link link) public LinkDto()
{ {
Id = link.Id;
Url = link.Url;
CreatedAt = link.CreatedAt;
} }
} }
} }

View File

@ -2,7 +2,7 @@ using MediatR;
namespace HN.Application namespace HN.Application
{ {
public sealed class ListLinksQuery : IRequest<LinkDTO[]> public sealed class ListLinksQuery : IRequest<LinkDto[]>
{ {
} }

View File

@ -2,10 +2,39 @@ using System.Threading;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using MediatR;
using HN.Domain;
namespace HN.Application namespace HN.Application
{ {
public sealed class ListLinksQueryHandler : IRequestHandler<ListLinksQuery, LinkDTO[]> // public static class LinkQueriesExtensions
// {
// public static IQueryable<LinkDto> AllAsDto(this DbSet<Link> links)
// {
// return from link in links
// select new LinkDto
// {
// Id = link.Id,
// Url = link.Url,
// CreatedAt = link.CreatedAt,
// UpVotes = link.Votes.Count(v => v.Type == VoteType.Up),
// DownVotes = link.Votes.Count(v => v.Type == VoteType.Down)
// };
// }
// public static IQueryable<LinkDto> ToLinkDto(this IQueryable<Link> links)
// {
// return links.Select(link => new LinkDto
// {
// Id = link.Id,
// Url = link.Url,
// CreatedAt = link.CreatedAt,
// UpVotes = link.Votes.Count(v => v.Type == VoteType.Up),
// DownVotes = link.Votes.Count(v => v.Type == VoteType.Down)
// });
// }
// }
public sealed class ListLinksQueryHandler : IRequestHandler<ListLinksQuery, LinkDto[]>
{ {
private readonly IDbContext _context; private readonly IDbContext _context;
@ -14,10 +43,17 @@ namespace HN.Application
_context = context; _context = context;
} }
public Task<LinkDTO[]> Handle(ListLinksQuery request, CancellationToken cancellationToken) public Task<LinkDto[]> Handle(ListLinksQuery request, CancellationToken cancellationToken)
{ {
var links = from link in _context.Links var links = from link in _context.Links
select new LinkDTO(link); select new LinkDto
{
Id = link.Id,
Url = link.Url,
CreatedAt = link.CreatedAt,
UpVotes = link.Votes.Count(v => v.Type == VoteType.Up),
DownVotes = link.Votes.Count(v => v.Type == VoteType.Down)
};
return Task.FromResult(links.ToArray()); return Task.FromResult(links.ToArray());
} }

View File

@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
using HN.Domain;
using MediatR;
namespace HN.Application
{
public sealed class VoteForLinkCommand : IRequest
{
[Required]
public Guid LinkId { get; set; }
[Required]
public VoteType Type { get; set; }
public VoteForLinkCommand(Guid linkId, VoteType type)
{
LinkId = linkId;
Type = type;
}
}
}

View File

@ -0,0 +1,36 @@
using System.Threading;
using System.Threading.Tasks;
using HN.Domain;
using MediatR;
namespace HN.Application
{
public sealed class VoteForLinkCommandHandler : IRequestHandler<VoteForLinkCommand>
{
private readonly ILinkRepository _linkRepository;
public VoteForLinkCommandHandler(ILinkRepository linkRepository)
{
_linkRepository = linkRepository;
}
public async Task<Unit> Handle(VoteForLinkCommand request, CancellationToken cancellationToken)
{
var link = await _linkRepository.GetByIdAsync(request.LinkId);
switch (request.Type)
{
case VoteType.Up:
link.Upvote();
break;
case VoteType.Down:
link.Downvote();
break;
}
await _linkRepository.UpdateAsync(link);
return Unit.Value;
}
}
}

View File

@ -3,6 +3,7 @@ using HN.Application;
using MediatR; using MediatR;
using System.Threading.Tasks; using System.Threading.Tasks;
using System; using System;
using HN.Domain;
namespace Website.Controllers namespace Website.Controllers
{ {
@ -32,6 +33,16 @@ namespace Website.Controllers
return View(new AddLinkCommand()); return View(new AddLinkCommand());
} }
[HttpPost("{controller}/{id:guid}/vote")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Vote(Guid id, string url, VoteType type)
{
await _bus.Send(new VoteForLinkCommand(id, type));
SetFlash($"Successfuly {type} for {url}!");
return RedirectToAction(nameof(Index));
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Create(AddLinkCommand command) public async Task<IActionResult> Create(AddLinkCommand command)

View File

@ -1,4 +1,4 @@
@model HN.Application.LinkDTO[] @model HN.Application.LinkDto[]
@{ @{
ViewData["Title"] = "Latest Links"; ViewData["Title"] = "Latest Links";
} }

View File

@ -1,3 +1,3 @@
@model HN.Application.LinkDTO @model HN.Application.LinkDto
<partial name="_LinkItem" model="@Model" /> <partial name="_LinkItem" model="@Model" />

View File

@ -1,3 +1,8 @@
@model HN.Application.LinkDTO @model HN.Application.LinkDto
<a asp-action="Show" asp-controller="Links" asp-route-id="@Model.Id">@Model.Url - created at @Model.CreatedAt.ToLocalTime()</a> <a asp-action="Show" asp-controller="Links" asp-route-id="@Model.Id">@Model.Url - created at @Model.CreatedAt.ToLocalTime() (👍: @Model.UpVotes / 👎: @Model.DownVotes)</a>
<form asp-controller="Links" asp-action="Vote" asp-route-id="@Model.Id" asp-route-url="@Model.Url" method="post">
<input type="submit" name="type" value="up">👍</button>
<input type="submit" name="type" value="down">👎</button>
</form>

View File

@ -3,7 +3,8 @@
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Information"
} }
} }
} }

View File

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace HN.Domain namespace HN.Domain
@ -5,5 +6,7 @@ namespace HN.Domain
public interface ILinkRepository public interface ILinkRepository
{ {
Task AddAsync(Link link); Task AddAsync(Link link);
Task UpdateAsync(Link link);
Task<Link> GetByIdAsync(Guid id);
} }
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace HN.Domain namespace HN.Domain
{ {
@ -7,17 +8,30 @@ namespace HN.Domain
public Guid Id { get; } public Guid Id { get; }
public string Url { get; } public string Url { get; }
public DateTime CreatedAt { get; } public DateTime CreatedAt { get; }
private List<Vote> _votes;
public IReadOnlyList<Vote> Votes { get { return _votes; } }
private Link(string url) private Link(string url)
{ {
this.Id = Guid.NewGuid(); this.Id = Guid.NewGuid();
this.CreatedAt = DateTime.UtcNow; this.CreatedAt = DateTime.UtcNow;
this.Url = url; this.Url = url;
this._votes = new List<Vote>();
} }
public static Link FromUrl(string url) public static Link FromUrl(string url)
{ {
return new Link(url); return new Link(url);
} }
public void Upvote()
{
_votes.Add(new Vote(VoteType.Up));
}
public void Downvote()
{
_votes.Add(new Vote(VoteType.Down));
}
} }
} }

16
Domain/Vote.cs Normal file
View File

@ -0,0 +1,16 @@
using System;
namespace HN.Domain
{
public sealed class Vote
{
public VoteType Type { get; private set; }
public DateTime CreatedAt { get; private set; }
public Vote(VoteType type)
{
Type = type;
CreatedAt = DateTime.UtcNow;
}
}
}

8
Domain/VoteType.cs Normal file
View File

@ -0,0 +1,8 @@
namespace HN.Domain
{
public enum VoteType
{
Up,
Down
}
}

View File

@ -13,6 +13,12 @@ namespace HN.Infrastructure.EntityTypes
builder.Property(o => o.Url).IsRequired().HasMaxLength(500); builder.Property(o => o.Url).IsRequired().HasMaxLength(500);
builder.Property(o => o.CreatedAt).IsRequired(); builder.Property(o => o.CreatedAt).IsRequired();
builder.HasIndex(o => o.Url).IsUnique(); builder.HasIndex(o => o.Url).IsUnique();
builder.HasMany<Vote>(o => o.Votes)
.WithOne()
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
} }
} }
} }

View File

@ -0,0 +1,18 @@
using HN.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HN.Infrastructure.EntityTypes
{
public sealed class VoteEntityType : IEntityTypeConfiguration<Vote>
{
public void Configure(EntityTypeBuilder<Vote> builder)
{
builder.ToTable("link_votes");
builder.HasKey("LinkId");
builder.Property(o => o.Type).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
}
}
}

View File

@ -1,11 +1,13 @@
using HN.Application; using HN.Application;
using HN.Domain; using HN.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace HN.Infrastructure namespace HN.Infrastructure
{ {
public sealed class HNDbContext : DbContext, IDbContext public sealed class HNDbContext : DbContext, IDbContext
{ {
private readonly ILoggerFactory _loggerFactory;
public DbSet<Link> Links { get; set; } public DbSet<Link> Links { get; set; }
public HNDbContext() public HNDbContext()
@ -13,9 +15,9 @@ namespace HN.Infrastructure
} }
public HNDbContext(DbContextOptions<HNDbContext> options) : base(options) public HNDbContext(DbContextOptions<HNDbContext> options, ILoggerFactory loggerFactory) : base(options)
{ {
_loggerFactory = loggerFactory;
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly); protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
@ -27,6 +29,8 @@ namespace HN.Infrastructure
{ {
optionsBuilder.UseSqlite("Data Source=:memory:"); optionsBuilder.UseSqlite("Data Source=:memory:");
} }
optionsBuilder.UseLoggerFactory(_loggerFactory);
} }
} }
} }

View File

@ -1,5 +1,7 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using HN.Domain; using HN.Domain;
using Microsoft.EntityFrameworkCore;
namespace HN.Infrastructure namespace HN.Infrastructure
{ {
@ -13,5 +15,15 @@ namespace HN.Infrastructure
{ {
await base.AddAsync(link); await base.AddAsync(link);
} }
public Task<Link> GetByIdAsync(Guid id)
{
return Entries.SingleOrDefaultAsync(o => o.Id == id);
}
public async Task UpdateAsync(Link link)
{
await base.UpdateAsync(link);
}
} }
} }

View File

@ -0,0 +1,75 @@
// <auto-generated />
using System;
using HN.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Infrastructure.Migrations
{
[DbContext(typeof(HNDbContext))]
[Migration("20201210094301_CreateLinkVote")]
partial class CreateLinkVote
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.0");
modelBuilder.Entity("HN.Domain.Link", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Url")
.IsUnique();
b.ToTable("links");
});
modelBuilder.Entity("HN.Domain.Vote", b =>
{
b.Property<Guid>("LinkId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("LinkId");
b.ToTable("link_votes");
});
modelBuilder.Entity("HN.Domain.Vote", b =>
{
b.HasOne("HN.Domain.Link", null)
.WithMany("Votes")
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HN.Domain.Link", b =>
{
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Infrastructure.Migrations
{
public partial class CreateLinkVote : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "link_votes",
columns: table => new
{
LinkId = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_link_votes", x => x.LinkId);
table.ForeignKey(
name: "FK_link_votes_links_LinkId",
column: x => x.LinkId,
principalTable: "links",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "link_votes");
}
}
}

View File

@ -1,43 +1,73 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using HN.Infrastructure; using HN.Infrastructure;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Infrastructure.Migrations namespace Infrastructure.Migrations
{ {
[DbContext(typeof(HNDbContext))] [DbContext(typeof(HNDbContext))]
partial class HNDbContextModelSnapshot : ModelSnapshot partial class HNDbContextModelSnapshot : ModelSnapshot
{ {
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "5.0.0"); .HasAnnotation("ProductVersion", "5.0.0");
modelBuilder.Entity("HN.Domain.Link", b => modelBuilder.Entity("HN.Domain.Link", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Url") b.Property<string>("Url")
.IsRequired() .IsRequired()
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Url") b.HasIndex("Url")
.IsUnique(); .IsUnique();
b.ToTable("links"); b.ToTable("links");
}); });
#pragma warning restore 612, 618
} modelBuilder.Entity("HN.Domain.Vote", b =>
} {
} b.Property<Guid>("LinkId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("LinkId");
b.ToTable("link_votes");
});
modelBuilder.Entity("HN.Domain.Vote", b =>
{
b.HasOne("HN.Domain.Link", null)
.WithMany("Votes")
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HN.Domain.Link", b =>
{
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -20,5 +20,10 @@ namespace HN.Infrastructure
await Entries.AddRangeAsync(entities); await Entries.AddRangeAsync(entities);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
protected async Task UpdateAsync(params TEntity[] entities)
{
await _context.SaveChangesAsync();
}
} }
} }