Convert db to EF Core; start on view page

Also returning skills with profile inquiries now, though that particular query is failing (current WIP) #2
This commit is contained in:
Daniel J. Summers 2021-01-06 23:18:54 -05:00
parent 97b3de1cea
commit ef12da01dc
26 changed files with 515 additions and 508 deletions

View File

@ -3,34 +3,4 @@
@inject NavigationManager nav @inject NavigationManager nav
@inject AppState state @inject AppState state
<p>@message</p> <p>@Message</p>
@code {
string message = "Logging you on with No Agenda Social...";
protected override async Task OnInitializedAsync()
{
// Exchange authorization code for a JWT
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
if (query.TryGetValue("code", out var authCode))
{
var logOnResult = await ServerApi.LogOn(http, authCode);
if (logOnResult.IsOk)
{
var logOn = logOnResult.Ok;
state.User = new UserInfo(logOn.CitizenId, logOn.Name);
state.Jwt = logOn.Jwt;
nav.NavigateTo("/citizen/dashboard");
}
else
{
message = logOnResult.Error;
}
}
else
{
message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
}
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.Citizen
{
public partial class Authorized : ComponentBase
{
/// <summary>
/// The message to be displayed on this page
/// </summary>
private string Message { get; set; } = "Logging you on with No Agenda Social...";
protected override async Task OnInitializedAsync()
{
// Exchange authorization code for a JWT
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
if (query.TryGetValue("code", out var authCode))
{
var logOnResult = await ServerApi.LogOn(http, authCode);
if (logOnResult.IsOk)
{
var logOn = logOnResult.Ok;
state.User = new UserInfo(logOn.CitizenId, logOn.Name);
state.Jwt = logOn.Jwt;
nav.NavigateTo("/citizen/dashboard");
}
else
{
Message = logOnResult.Error;
}
}
else
{
Message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
}
}
}
}

View File

@ -1,6 +1,8 @@
@page "/citizen/dashboard" @page "/citizen/dashboard"
@inject HttpClient http
@inject AppState state
<h3>Welcome, @State.User!.Name!</h3> <h3>Welcome, @state.User!.Name!</h3>
@if (RetrievingData) @if (RetrievingData)
{ {
@ -8,34 +10,25 @@
} }
else else
{ {
if (Profile != null) <ErrorList Errors=@ErrorMessages>
{ @if (Profile != null)
{
<p>
Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently
lists @Profile.Skills.Length skill@(Profile.Skills.Length != 1 ? "s" : "").
</p>
}
else
{
<p>You do not have an employment profile established; click &ldquo;Profile&rdquo;* in the menu to get started!</p>
}
<p> <p>
Your employment profile was last updated <FullDateTime TheDate="@Profile.LastUpdatedOn" />. Your profile currently There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment
lists @SkillCount skill@(SkillCount != 1 ? "s" : ""). profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation.
@if (ProfileCount > 0)
{
<text>Take a look around and see if you can help them find work!</text>
}
</p> </p>
} </ErrorList>
else
{
<p>You do not have an employment profile established; click &ldquo;Profile&rdquo;* in the menu to get started!</p>
}
<p>
There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment
profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation.
@if (ProfileCount > 0)
{
<text>Take a look around and see if you can help them find work!</text>
}
</p>
}
@if (ErrorMessages.Count > 0)
{
<p><strong>The following error(s) occurred:</strong></p>
<p>
@foreach (var msg in ErrorMessages)
{
@msg<br>
}
</p>
} }

View File

@ -1,9 +1,8 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared.Api;
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Domain = JobsJobsJobs.Shared;
namespace JobsJobsJobs.Client.Pages.Citizen namespace JobsJobsJobs.Client.Pages.Citizen
{ {
@ -20,46 +19,27 @@ namespace JobsJobsJobs.Client.Pages.Citizen
/// <summary> /// <summary>
/// The user's profile /// The user's profile
/// </summary> /// </summary>
private Profile? Profile { get; set; } = null; private Domain.Profile? Profile { get; set; } = null;
/// <summary>
/// The number of skills in the user's profile
/// </summary>
private long SkillCount { get; set; } = 0L;
/// <summary> /// <summary>
/// The number of profiles /// The number of profiles
/// </summary> /// </summary>
private long ProfileCount { get; set; } = 0L; private int ProfileCount { get; set; }
/// <summary> /// <summary>
/// Error messages from data access /// Error messages from data access
/// </summary> /// </summary>
private IList<string> ErrorMessages { get; } = new List<string>(); private IList<string> ErrorMessages { get; } = new List<string>();
/// <summary>
/// The HTTP client to use for API access
/// </summary>
[Inject]
public HttpClient Http { get; set; } = default!;
/// <summary>
/// The current application state
/// </summary>
[Inject]
public AppState State { get; set; } = default!;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (State.User != null) if (state.User != null)
{ {
ServerApi.SetJwt(Http, State); ServerApi.SetJwt(http, state);
var profileTask = ServerApi.RetrieveProfile(Http, State); var profileTask = ServerApi.RetrieveProfile(http, state);
var profileCountTask = ServerApi.RetrieveOne<Count>(Http, "profile/count"); var profileCountTask = ServerApi.RetrieveOne<Count>(http, "profile/count");
var skillCountTask = ServerApi.RetrieveOne<Count>(Http, "profile/skill-count");
await Task.WhenAll(profileTask, profileCountTask, skillCountTask); await Task.WhenAll(profileTask, profileCountTask);
if (profileTask.Result.IsOk) if (profileTask.Result.IsOk)
{ {
@ -79,18 +59,8 @@ namespace JobsJobsJobs.Client.Pages.Citizen
ErrorMessages.Add(profileCountTask.Result.Error); ErrorMessages.Add(profileCountTask.Result.Error);
} }
if (skillCountTask.Result.IsOk)
{
SkillCount = skillCountTask.Result.Ok?.Value ?? 0;
}
else
{
ErrorMessages.Add(skillCountTask.Result.Error);
}
RetrievingData = false; RetrievingData = false;
} }
} }
} }
} }

View File

@ -1,4 +1,7 @@
@page "/citizen/profile" @page "/citizen/profile"
@inject HttpClient http
@inject AppState state
@inject IToastService toast
<h3>Employment Profile</h3> <h3>Employment Profile</h3>
@ -14,12 +17,12 @@
} }
else else
{ {
<EditForm Model="@ProfileForm" OnValidSubmit="@SaveProfile"> <EditForm Model=@ProfileForm OnValidSubmit=@SaveProfile>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="form-row"> <div class="form-row">
<div class="col"> <div class="col">
<div class="form-check"> <div class="form-check">
<InputCheckbox id="seeking" class="form-check-input" @bind-Value="@ProfileForm.IsSeekingEmployment" /> <InputCheckbox id="seeking" class="form-check-input" @bind-Value=@ProfileForm.IsSeekingEmployment />
<label for="seeking" class="form-check-label">I am currently seeking employment</label> <label for="seeking" class="form-check-label">I am currently seeking employment</label>
</div> </div>
</div> </div>
@ -28,22 +31,22 @@
<div class="col col-xs-12 col-sm-6 col-md-4"> <div class="col col-xs-12 col-sm-6 col-md-4">
<div class="form-group"> <div class="form-group">
<label for="continentId" class="jjj-required">Continent</label> <label for="continentId" class="jjj-required">Continent</label>
<InputSelect id="continentId" @bind-Value="@ProfileForm.ContinentId" class="form-control"> <InputSelect id="continentId" @bind-Value=@ProfileForm.ContinentId class="form-control">
<option>&ndash; Select &ndash;</option> <option>&ndash; Select &ndash;</option>
@foreach (var (id, name) in Continents) @foreach (var (id, name) in Continents)
{ {
<option value="@id">@name</option> <option value="@id">@name</option>
} }
</InputSelect> </InputSelect>
<ValidationMessage For="@(() => ProfileForm.ContinentId)" /> <ValidationMessage For=@(() => ProfileForm.ContinentId) />
</div> </div>
</div> </div>
<div class="col col-xs-12 col-sm-6 col-md-8"> <div class="col col-xs-12 col-sm-6 col-md-8">
<div class="form-group"> <div class="form-group">
<label for="region" class="jjj-required">Region</label> <label for="region" class="jjj-required">Region</label>
<InputText id="region" @bind-Value="@ProfileForm.Region" class="form-control" <InputText id="region" @bind-Value=@ProfileForm.Region class="form-control"
placeholder="Country, state, geographic area, etc." /> placeholder="Country, state, geographic area, etc." />
<ValidationMessage For="@(() => ProfileForm.Region)" /> <ValidationMessage For=@(() => ProfileForm.Region) />
</div> </div>
</div> </div>
</div> </div>
@ -51,21 +54,21 @@
<div class="col"> <div class="col">
<div class="form-group"> <div class="form-group">
<label for="bio" class="jjj-required">Professional Biography</label> <label for="bio" class="jjj-required">Professional Biography</label>
<MarkdownEditor Id="bio" @bind-Text="@ProfileForm.Biography" /> <MarkdownEditor Id="bio" @bind-Text=@ProfileForm.Biography />
<ValidationMessage For="@(() => ProfileForm.Biography)" /> <ValidationMessage For=@(() => ProfileForm.Biography) />
</div> </div>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4"> <div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
<div class="form-check"> <div class="form-check">
<InputCheckbox id="isRemote" class="form-check-input" @bind-Value="@ProfileForm.RemoteWork" /> <InputCheckbox id="isRemote" class="form-check-input" @bind-Value=@ProfileForm.RemoteWork />
<label for="isRemote" class="form-check-label">I am looking for remote work</label> <label for="isRemote" class="form-check-label">I am looking for remote work</label>
</div> </div>
</div> </div>
<div class="col col-xs-12 col-sm-12 col-md-4"> <div class="col col-xs-12 col-sm-12 col-md-4">
<div class="form-check"> <div class="form-check">
<InputCheckbox id="isFull" class="form-check-input" @bind-Value="@ProfileForm.FullTime" /> <InputCheckbox id="isFull" class="form-check-input" @bind-Value=@ProfileForm.FullTime />
<label for="isFull" class="form-check-label">I am looking for full-time work</label> <label for="isFull" class="form-check-label">I am looking for full-time work</label>
</div> </div>
</div> </div>
@ -73,11 +76,11 @@
<hr> <hr>
<h4> <h4>
Skills &nbsp; Skills &nbsp;
<button type="button" class="btn btn-outline-primary" @onclick="@AddNewSkill">Add a Skill</button> <button type="button" class="btn btn-outline-primary" @onclick=@AddNewSkill>Add a Skill</button>
</h4> </h4>
@foreach (var skill in ProfileForm.Skills) @foreach (var skill in ProfileForm.Skills)
{ {
<SkillEdit Skill="@skill" OnRemove="@RemoveSkill" /> <SkillEdit Skill=@skill OnRemove=@RemoveSkill />
} }
<hr> <hr>
<h4>Experience</h4> <h4>Experience</h4>
@ -88,13 +91,13 @@
</p> </p>
<div class="form-row"> <div class="form-row">
<div class="col"> <div class="col">
<MarkdownEditor Id="experience" @bind-Text="@ProfileForm.Experience" /> <MarkdownEditor Id="experience" @bind-Text=@ProfileForm.Experience />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="col"> <div class="col">
<div class="form-check"> <div class="form-check">
<InputCheckbox id="isPublic" class="form-check-input" @bind-Value="@ProfileForm.IsPublic" /> <InputCheckbox id="isPublic" class="form-check-input" @bind-Value=@ProfileForm.IsPublic />
<label for="isPublic" class="form-check-label"> <label for="isPublic" class="form-check-label">
Allow my profile to be searched publicly (outside NA Social) Allow my profile to be searched publicly (outside NA Social)
</label> </label>

View File

@ -1,10 +1,8 @@
using Blazored.Toast.Services; using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api; using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -40,32 +38,13 @@ namespace JobsJobsJobs.Client.Pages.Citizen
/// </summary> /// </summary>
private IList<string> ErrorMessages { get; } = new List<string>(); private IList<string> ErrorMessages { get; } = new List<string>();
/// <summary>
/// HTTP client instance to use for API access
/// </summary>
[Inject]
private HttpClient Http { get; set; } = default!;
/// <summary>
/// Application state
/// </summary>
[Inject]
private AppState State { get; set; } = default!;
/// <summary>
/// Toast service
/// </summary>
[Inject]
private IToastService Toasts { get; set; } = default!;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
ServerApi.SetJwt(Http, State); ServerApi.SetJwt(http, state);
var continentTask = ServerApi.RetrieveMany<Continent>(Http, "continent/all"); var continentTask = ServerApi.RetrieveMany<Continent>(http, "continent/all");
var profileTask = ServerApi.RetrieveProfile(Http, State); var profileTask = ServerApi.RetrieveProfile(http, state);
var skillTask = ServerApi.RetrieveMany<Skill>(Http, "profile/skills");
await Task.WhenAll(continentTask, profileTask, skillTask); await Task.WhenAll(continentTask, profileTask);
if (continentTask.Result.IsOk) if (continentTask.Result.IsOk)
{ {
@ -81,28 +60,11 @@ namespace JobsJobsJobs.Client.Pages.Citizen
ProfileForm = (profileTask.Result.Ok == null) ProfileForm = (profileTask.Result.Ok == null)
? new ProfileForm() ? new ProfileForm()
: ProfileForm.FromProfile(profileTask.Result.Ok); : ProfileForm.FromProfile(profileTask.Result.Ok);
}
else
{
ErrorMessages.Add(profileTask.Result.Error);
}
if (skillTask.Result.IsOk)
{
foreach (var skill in skillTask.Result.Ok)
{
ProfileForm.Skills.Add(new SkillForm
{
Id = skill.Id.ToString(),
Description = skill.Description,
Notes = skill.Notes ?? ""
});
}
if (ProfileForm.Skills.Count == 0) AddNewSkill(); if (ProfileForm.Skills.Count == 0) AddNewSkill();
} }
else else
{ {
ErrorMessages.Add(skillTask.Result.Error); ErrorMessages.Add(profileTask.Result.Error);
} }
AllLoaded = true; AllLoaded = true;
@ -132,16 +94,16 @@ namespace JobsJobsJobs.Client.Pages.Citizen
.ToList(); .ToList();
foreach (var blankSkill in blankSkills) ProfileForm.Skills.Remove(blankSkill); foreach (var blankSkill in blankSkills) ProfileForm.Skills.Remove(blankSkill);
var res = await Http.PostAsJsonAsync("/api/profile/save", ProfileForm); var res = await http.PostAsJsonAsync("/api/profile/save", ProfileForm);
if (res.IsSuccessStatusCode) if (res.IsSuccessStatusCode)
{ {
Toasts.ShowSuccess("Profile Saved Successfully"); toast.ShowSuccess("Profile Saved Successfully");
} }
else else
{ {
var error = await res.Content.ReadAsStringAsync(); var error = await res.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(error)) error = $"- {error}"; if (!string.IsNullOrEmpty(error)) error = $"- {error}";
Toasts.ShowError($"{(int)res.StatusCode} {error}"); toast.ShowError($"{(int)res.StatusCode} {error}");
} }
} }

View File

@ -10,7 +10,7 @@
Do you not understand the terms in the paragraph above? No worries; just head over to Do you not understand the terms in the paragraph above? No worries; just head over to
<a href="https://noagendashow.net"> <a href="https://noagendashow.net">
The Best Podcast in the Universe The Best Podcast in the Universe
</a> <em><a class="audio" @onclick="PlayTrue">(it&rsquo;s true!)</a></em> and find out what you&rsquo;re missing. </a> <em><a class="audio" @onclick=@PlayTrue>(it&rsquo;s true!)</a></em> and find out what you&rsquo;re missing.
</p> </p>
<audio id="itstrue"> <audio id="itstrue">
<source src="/audio/thats-true.mp3"> <source src="/audio/thats-true.mp3">

View File

@ -0,0 +1,15 @@
@page "/profile/view/{Id}"
@inject HttpClient http
@inject AppState state
@if (IsLoading)
{
<p>Loading profile...</p>
}
else
{
<ErrorList Errors=@ErrorMessages>
<h3>View Profile</h3>
</ErrorList>
}

View File

@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Domain = JobsJobsJobs.Shared;
namespace JobsJobsJobs.Client.Pages.Profile
{
public partial class View : ComponentBase
{
private bool IsLoading { get; set; } = true;
private Domain.Citizen? Citizen { get; set; }
private Domain.Profile? Profile { get; set; }
private IList<string> ErrorMessages { get; } = new List<string>();
[Parameter]
public string Id { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
ServerApi.SetJwt(http, state);
var citizenTask = ServerApi.RetrieveOne<Domain.Citizen>(http, $"/api/citizen/{Id}");
var profileTask = ServerApi.RetrieveOne<Domain.Profile>(http, $"/api/profile/get/{Id}");
await Task.WhenAll(citizenTask, profileTask);
if (citizenTask.Result.IsOk && citizenTask.Result.Ok != null)
{
Citizen = citizenTask.Result.Ok;
}
else if (citizenTask.Result.IsOk)
{
ErrorMessages.Add("Citizen not found");
}
else
{
ErrorMessages.Add(citizenTask.Result.Error);
}
if (profileTask.Result.IsOk && profileTask.Result.Ok != null)
{
Profile = profileTask.Result.Ok;
}
else if (profileTask.Result.IsOk)
{
ErrorMessages.Add("Profile not found");
}
else
{
ErrorMessages.Add(profileTask.Result.Error);
}
}
}
}

View File

@ -64,8 +64,13 @@ namespace JobsJobsJobs.Client
/// </summary> /// </summary>
/// <param name="http">The HTTP client whose authentication header should be set</param> /// <param name="http">The HTTP client whose authentication header should be set</param>
/// <param name="state">The current application state</param> /// <param name="state">The current application state</param>
public static void SetJwt(HttpClient http, AppState state) => public static void SetJwt(HttpClient http, AppState state)
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt); {
if (state.User != null)
{
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt);
}
}
/// <summary> /// <summary>
/// Log on a user with the authorization code received from No Agenda Social /// Log on a user with the authorization code received from No Agenda Social

View File

@ -0,0 +1,22 @@
@if (Errors.Count == 0)
{
@ChildContent
}
else
{
<p>The following error@(Errors.Count == 1 ? "" : "s") occurred:</p>
<ul>
@foreach (var msg in Errors)
{
<li>@msg</li>
}
</ul>
}
@code {
[Parameter]
public IList<string> Errors { get; set; } = default!;
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
}

View File

@ -4,7 +4,6 @@ using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using NodaTime; using NodaTime;
using Npgsql;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers namespace JobsJobsJobs.Server.Areas.Api.Controllers
@ -27,17 +26,17 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
private readonly IClock _clock; private readonly IClock _clock;
/// <summary> /// <summary>
/// The data connection to use for this request /// The data context to use for this request
/// </summary> /// </summary>
private readonly NpgsqlConnection _db; private readonly JobsDbContext _db;
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
/// <param name="config">The authorization configuration section</param> /// <param name="config">The authorization configuration section</param>
/// <param name="clock">The NodaTime clock instance</param> /// <param name="clock">The NodaTime clock instance</param>
/// <param name="db">The data connection to use for this request</param> /// <param name="db">The data context to use for this request</param>
public CitizenController(IConfiguration config, IClock clock, NpgsqlConnection db) public CitizenController(IConfiguration config, IClock clock, JobsDbContext db)
{ {
_config = config.GetSection("Auth"); _config = config.GetSection("Auth");
_clock = clock; _clock = clock;
@ -56,7 +55,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
var account = accountResult.Ok; var account = accountResult.Ok;
var now = _clock.GetCurrentInstant(); var now = _clock.GetCurrentInstant();
await _db.OpenAsync();
var citizen = await _db.FindCitizenByNAUser(account.Username); var citizen = await _db.FindCitizenByNAUser(account.Username);
if (citizen == null) if (citizen == null)
{ {
@ -71,13 +69,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
DisplayName = account.DisplayName, DisplayName = account.DisplayName,
LastSeenOn = now LastSeenOn = now
}; };
await _db.UpdateCitizenOnLogOn(citizen); _db.UpdateCitizen(citizen);
} }
await _db.SaveChangesAsync();
// Step 3 - Generate JWT // Step 3 - Generate JWT
var jwt = Auth.CreateJwt(citizen, _config); var jwt = Auth.CreateJwt(citizen, _config);
return new JsonResult(new LogOnSuccess(jwt, citizen.Id.ToString(), citizen.DisplayName)); return new JsonResult(new LogOnSuccess(jwt, citizen.Id.ToString(), citizen.DisplayName));
} }
[HttpGet("get/{id}")]
public async Task<IActionResult> GetCitizenById([FromRoute] string id)
{
var citizen = await _db.FindCitizenById(CitizenId.Parse(id));
return citizen == null ? NotFound() : Ok(citizen);
}
} }
} }

View File

@ -1,7 +1,6 @@
using JobsJobsJobs.Server.Data; using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Npgsql;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers namespace JobsJobsJobs.Server.Areas.Api.Controllers
@ -15,24 +14,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
public class ContinentController : ControllerBase public class ContinentController : ControllerBase
{ {
/// <summary> /// <summary>
/// The database connection to use for this request /// The data context to use for this request
/// </summary> /// </summary>
private readonly NpgsqlConnection _db; private readonly JobsDbContext _db;
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
/// <param name="db">The database connection to use for this request</param> /// <param name="db">The data context to use for this request</param>
public ContinentController(NpgsqlConnection db) public ContinentController(JobsDbContext db)
{ {
_db = db; _db = db;
} }
[HttpGet("all")] [HttpGet("all")]
public async Task<IActionResult> All() public async Task<IActionResult> All() =>
{ Ok(await _db.AllContinents());
await _db.OpenAsync();
return Ok(await _db.AllContinents());
}
} }
} }

View File

@ -2,12 +2,8 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api; using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Npgsql;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
@ -24,9 +20,9 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
public class ProfileController : ControllerBase public class ProfileController : ControllerBase
{ {
/// <summary> /// <summary>
/// The database connection /// The data context
/// </summary> /// </summary>
private readonly NpgsqlConnection _db; private readonly JobsDbContext _db;
/// <summary> /// <summary>
/// The NodaTime clock instance /// The NodaTime clock instance
@ -36,8 +32,8 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
/// <param name="db">The database connection to use for this request</param> /// <param name="db">The data context to use for this request</param>
public ProfileController(NpgsqlConnection db, IClock clock) public ProfileController(JobsDbContext db, IClock clock)
{ {
_db = db; _db = db;
_clock = clock; _clock = clock;
@ -48,10 +44,12 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// </summary> /// </summary>
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value); private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
// This returns 204 to indicate that there is no profile data for the current citizen (if, of course, that is
// the case. The version where an ID is specified returns 404, which is an error condition, as it should not
// occur unless someone is messing with a URL.
[HttpGet("")] [HttpGet("")]
public async Task<IActionResult> Get() public async Task<IActionResult> Get()
{ {
await _db.OpenAsync();
var profile = await _db.FindProfileByCitizen(CurrentCitizenId); var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
return profile == null ? NoContent() : Ok(profile); return profile == null ? NoContent() : Ok(profile);
} }
@ -59,9 +57,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
[HttpPost("save")] [HttpPost("save")]
public async Task<IActionResult> Save(ProfileForm form) public async Task<IActionResult> Save(ProfileForm form)
{ {
await _db.OpenAsync();
var txn = await _db.BeginTransactionAsync();
// Profile // Profile
var existing = await _db.FindProfileByCitizen(CurrentCitizenId); var existing = await _db.FindProfileByCitizen(CurrentCitizenId);
var profile = existing == null var profile = existing == null
@ -93,29 +88,27 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
foreach (var skill in skills) await _db.SaveSkill(skill); foreach (var skill in skills) await _db.SaveSkill(skill);
await _db.DeleteMissingSkills(CurrentCitizenId, skills.Select(s => s.Id)); await _db.DeleteMissingSkills(CurrentCitizenId, skills.Select(s => s.Id));
await txn.CommitAsync(); await _db.SaveChangesAsync();
return Ok(); return Ok();
} }
[HttpGet("skills")] [HttpGet("skills")]
public async Task<IActionResult> GetSkills() public async Task<IActionResult> GetSkills() =>
{ Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
await _db.OpenAsync();
return Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
}
[HttpGet("count")] [HttpGet("count")]
public async Task<IActionResult> GetProfileCount() public async Task<IActionResult> GetProfileCount() =>
{ Ok(new Count(await _db.CountProfiles()));
await _db.OpenAsync();
return Ok(new Count(await _db.CountProfiles()));
}
[HttpGet("skill-count")] [HttpGet("skill-count")]
public async Task<IActionResult> GetSkillCount() public async Task<IActionResult> GetSkillCount() =>
Ok(new Count(await _db.CountSkillsByCitizen(CurrentCitizenId)));
[HttpGet("get/{id}")]
public async Task<IActionResult> GetProfileForCitizen([FromRoute] string id)
{ {
await _db.OpenAsync(); var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
return Ok(new Count(await _db.CountSkills(CurrentCitizenId))); return profile == null ? NotFound() : Ok(profile);
} }
} }
} }

View File

@ -1,80 +1,46 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using Npgsql; using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data namespace JobsJobsJobs.Server.Data
{ {
/// <summary> /// <summary>
/// Extensions to the NpgslConnection type supporting the manipulation of citizens /// Extensions to JobsDbContext supporting the manipulation of citizens
/// </summary> /// </summary>
public static class CitizenExtensions public static class CitizenExtensions
{ {
/// <summary> /// <summary>
/// Populate a citizen object from the given data reader /// Retrieve a citizen by their Jobs, Jobs, Jobs ID
/// </summary> /// </summary>
/// <param name="rdr">The data reader from which the values should be obtained</param> /// <param name="citizenId">The ID of the citizen to retrieve</param>
/// <returns>A populated citizen</returns> /// <returns>The citizen, or null if not found</returns>
private static Citizen ToCitizen(NpgsqlDataReader rdr) => public static async Task<Citizen?> FindCitizenById(this JobsDbContext db, CitizenId citizenId) =>
new Citizen(CitizenId.Parse(rdr.GetString("id")), rdr.GetString("na_user"), rdr.GetString("display_name"), await db.Citizens.AsNoTracking()
rdr.GetString("profile_url"), rdr.GetInstant("joined_on"), rdr.GetInstant("last_seen_on")); .SingleOrDefaultAsync(c => c.Id == citizenId)
.ConfigureAwait(false);
/// <summary> /// <summary>
/// Retrieve a citizen by their No Agenda Social user name /// Retrieve a citizen by their No Agenda Social user name
/// </summary> /// </summary>
/// <param name="naUser">The NAS user name</param> /// <param name="naUser">The NAS user name</param>
/// <returns>The citizen, or null if not found</returns> /// <returns>The citizen, or null if not found</returns>
public static async Task<Citizen?> FindCitizenByNAUser(this NpgsqlConnection conn, string naUser) public static async Task<Citizen?> FindCitizenByNAUser(this JobsDbContext db, string naUser) =>
{ await db.Citizens.AsNoTracking()
using var cmd = conn.CreateCommand(); .SingleOrDefaultAsync(c => c.NaUser == naUser)
cmd.CommandText = "SELECT * FROM citizen WHERE na_user = @na_user"; .ConfigureAwait(false);
cmd.AddString("na_user", naUser);
using NpgsqlDataReader rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
if (await rdr.ReadAsync().ConfigureAwait(false)) return ToCitizen(rdr);
return null;
}
/// <summary> /// <summary>
/// Add a citizen /// Add a citizen
/// </summary> /// </summary>
/// <param name="citizen">The citizen to be added</param> /// <param name="citizen">The citizen to be added</param>
public static async Task AddCitizen(this NpgsqlConnection conn, Citizen citizen) public static async Task AddCitizen(this JobsDbContext db, Citizen citizen) =>
{ await db.Citizens.AddAsync(citizen);
using var cmd = conn.CreateCommand();
cmd.CommandText =
@"INSERT INTO citizen (
na_user, display_name, profile_url, joined_on, last_seen_on, id
) VALUES(
@na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id
)";
cmd.AddString("id", citizen.Id);
cmd.AddString("na_user", citizen.NaUser);
cmd.AddString("display_name", citizen.DisplayName);
cmd.AddString("profile_url", citizen.ProfileUrl);
cmd.AddInstant("joined_on", citizen.JoinedOn);
cmd.AddInstant("last_seen_on", citizen.LastSeenOn);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
/// <summary> /// <summary>
/// Update a citizen after they have logged on (update last seen, sync display name) /// Update a citizen after they have logged on (update last seen, sync display name)
/// </summary> /// </summary>
/// <param name="citizen">The updated citizen</param> /// <param name="citizen">The updated citizen</param>
public static async Task UpdateCitizenOnLogOn(this NpgsqlConnection conn, Citizen citizen) public static void UpdateCitizen(this JobsDbContext db, Citizen citizen) =>
{ db.Entry(citizen).State = EntityState.Modified;
using var cmd = conn.CreateCommand();
cmd.CommandText =
@"UPDATE citizen
SET display_name = @display_name,
last_seen_on = @last_seen_on
WHERE id = @id";
cmd.AddString("id", citizen.Id);
cmd.AddString("display_name", citizen.DisplayName);
cmd.AddInstant("last_seen_on", citizen.LastSeenOn);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
} }
} }

View File

@ -1,6 +1,7 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using Npgsql; using Microsoft.EntityFrameworkCore;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data namespace JobsJobsJobs.Server.Data
@ -10,31 +11,21 @@ namespace JobsJobsJobs.Server.Data
/// </summary> /// </summary>
public static class ContinentExtensions public static class ContinentExtensions
{ {
/// <summary>
/// Create a continent from the current row in the data reader
/// </summary>
/// <param name="rdr">The data reader</param>
/// <returns>The current row's values as a continent object</returns>
private static Continent ToContinent(NpgsqlDataReader rdr) =>
new Continent(ContinentId.Parse(rdr.GetString("id")), rdr.GetString("name"));
/// <summary> /// <summary>
/// Retrieve all continents /// Retrieve all continents
/// </summary> /// </summary>
/// <returns>All continents</returns> /// <returns>All continents</returns>
public static async Task<IEnumerable<Continent>> AllContinents(this NpgsqlConnection conn) public static async Task<IEnumerable<Continent>> AllContinents(this JobsDbContext db) =>
{ await db.Continents.AsNoTracking().OrderBy(c => c.Name).ToListAsync().ConfigureAwait(false);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM continent ORDER BY name";
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); /// <summary>
var continents = new List<Continent>(); /// Retrieve a continent by its ID
while (await rdr.ReadAsync()) /// </summary>
{ /// <param name="continentId">The ID of the continent to retrieve</param>
continents.Add(ToContinent(rdr)); /// <returns>The continent matching the ID</returns>
} public static async Task<Continent> FindContinentById(this JobsDbContext db, ContinentId continentId) =>
await db.Continents.AsNoTracking()
return continents; .SingleAsync(c => c.Id == continentId)
} .ConfigureAwait(false);
} }
} }

View File

@ -0,0 +1,53 @@
using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Converters used to translate between database and domain types
/// </summary>
public static class Converters
{
/// <summary>
/// Citizen ID converter
/// </summary>
public static readonly ValueConverter<CitizenId, string> CitizenIdConverter =
new ValueConverter<CitizenId, string>(v => v.ToString(), v => CitizenId.Parse(v));
/// <summary>
/// Continent ID converter
/// </summary>
public static readonly ValueConverter<ContinentId, string> ContinentIdConverter =
new ValueConverter<ContinentId, string>(v => v.ToString(), v => ContinentId.Parse(v));
/// <summary>
/// Markdown converter
/// </summary>
public static readonly ValueConverter<MarkdownString, string> MarkdownStringConverter =
new ValueConverter<MarkdownString, string>(v => v.Text, v => new MarkdownString(v));
/// <summary>
/// Markdown converter for possibly-null values
/// </summary>
public static readonly ValueConverter<MarkdownString?, string?> OptionalMarkdownStringConverter =
new ValueConverter<MarkdownString?, string?>(
v => v == null ? null : v.Text,
v => v == null ? null : new MarkdownString(v));
/// <summary>
/// Skill ID converter
/// </summary>
public static readonly ValueConverter<SkillId, string> SkillIdConverter =
new ValueConverter<SkillId, string>(v => v.ToString(), v => SkillId.Parse(v));
/// <summary>
/// Success ID converter
/// </summary>
public static readonly ValueConverter<SuccessId, string> SuccessIdConverter =
new ValueConverter<SuccessId, string>(v => v.ToString(), v => SuccessId.Parse(v));
}
}

View File

@ -0,0 +1,111 @@
using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Data context for Jobs, Jobs, Jobs
/// </summary>
public class JobsDbContext : DbContext
{
/// <summary>
/// Citizens (users known to us)
/// </summary>
public DbSet<Citizen> Citizens { get; set; } = default!;
/// <summary>
/// Continents (large land masses - 7 of them!)
/// </summary>
public DbSet<Continent> Continents { get; set; } = default!;
/// <summary>
/// Employment profiles
/// </summary>
public DbSet<Profile> Profiles { get; set; } = default!;
/// <summary>
/// Skills held by citizens of Gitmo Nation
/// </summary>
public DbSet<Skill> Skills { get; set; } = default!;
/// <summary>
/// Success stories from the site
/// </summary>
public DbSet<Success> Successes { get; set; } = default!;
/// <summary>
/// Constructor
/// </summary>
/// <param name="options">The options to use to configure this instance</param>
public JobsDbContext(DbContextOptions<JobsDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Citizen>(m =>
{
m.ToTable("citizen", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.NaUser).HasColumnName("na_user").IsRequired().HasMaxLength(50);
m.Property(e => e.DisplayName).HasColumnName("display_name").IsRequired().HasMaxLength(255);
m.Property(e => e.ProfileUrl).HasColumnName("profile_url").IsRequired().HasMaxLength(1_024);
m.Property(e => e.JoinedOn).HasColumnName("joined_on").IsRequired();
m.Property(e => e.LastSeenOn).HasColumnName("last_seen_on").IsRequired();
m.HasIndex(e => e.NaUser).IsUnique();
});
modelBuilder.Entity<Continent>(m =>
{
m.ToTable("continent", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.ContinentIdConverter);
m.Property(e => e.Name).HasColumnName("name").IsRequired().HasMaxLength(255);
});
modelBuilder.Entity<Profile>(m =>
{
m.ToTable("profile", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.SeekingEmployment).HasColumnName("seeking_employment").IsRequired();
m.Property(e => e.IsPublic).HasColumnName("is_public").IsRequired();
m.Property(e => e.ContinentId).HasColumnName("continent_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.ContinentIdConverter);
m.Property(e => e.Region).HasColumnName("region").IsRequired().HasMaxLength(255);
m.Property(e => e.RemoteWork).HasColumnName("remote_work").IsRequired();
m.Property(e => e.FullTime).HasColumnName("full_time").IsRequired();
m.Property(e => e.Biography).HasColumnName("biography").IsRequired()
.HasConversion(Converters.MarkdownStringConverter);
m.Property(e => e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired();
m.Property(e => e.Experience).HasColumnName("experience")
.HasConversion(Converters.OptionalMarkdownStringConverter);
});
modelBuilder.Entity<Skill>(m =>
{
m.ToTable("skill", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.SkillIdConverter);
m.Property(e => e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.Description).HasColumnName("skill").IsRequired().HasMaxLength(100);
m.Property(e => e.Notes).HasColumnName("notes").HasMaxLength(100);
});
modelBuilder.Entity<Success>(m =>
{
m.ToTable("success", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.SuccessIdConverter);
m.Property(e => e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired();
m.Property(e => e.Story).HasColumnName("story")
.HasConversion(Converters.OptionalMarkdownStringConverter);
});
}
}
}

View File

@ -1,93 +0,0 @@
using JobsJobsJobs.Shared;
using NodaTime;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Extensions to the Npgsql data reader
/// </summary>
public static class NpgsqlExtensions
{
#region Data Reader
/// <summary>
/// Get a boolean by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as a boolean</param>
/// <returns>The specified field as a boolean</returns>
public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name));
/// <summary>
/// Get an Instant by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as an Instant</param>
/// <returns>The specified field as an Instant</returns>
public static Instant GetInstant(this NpgsqlDataReader rdr, string name) =>
rdr.GetFieldValue<Instant>(rdr.GetOrdinal(name));
/// <summary>
/// Get a 64-bit integer by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as a 64-bit integer</param>
/// <returns>The specified field as a 64-bit integer</returns>
public static long GetInt64(this NpgsqlDataReader rdr, string name) => rdr.GetInt64(rdr.GetOrdinal(name));
/// <summary>
/// Get a string by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as a string</param>
/// <returns>The specified field as a string</returns>
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
/// <summary>
/// Determine if a column is null
/// </summary>
/// <param name="name">The name of the column to check</param>
/// <returns>True if the column is null, false if not</returns>
public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name));
#endregion
#region Command
/// <summary>
/// Add a string parameter
/// </summary>
/// <param name="name">The name of the parameter</param>
/// <param name="value">The value of the parameter</param>
public static void AddString(this NpgsqlCommand cmd, string name, object value) =>
cmd.Parameters.Add(
new NpgsqlParameter<string>($"@{name}", value is string @val ? @val : value.ToString()!));
/// <summary>
/// Add a boolean parameter
/// </summary>
/// <param name="name">The name of the parameter</param>
/// <param name="value">The value of the parameter</param>
public static void AddBool(this NpgsqlCommand cmd, string name, bool value) =>
cmd.Parameters.Add(new NpgsqlParameter<bool>($"@{name}", value));
/// <summary>
/// Add an Instant parameter
/// </summary>
/// <param name="name">The name of the parameter</param>
/// <param name="value">The value of the parameter</param>
public static void AddInstant(this NpgsqlCommand cmd, string name, Instant value) =>
cmd.Parameters.Add(new NpgsqlParameter<Instant>($"@{name}", value));
/// <summary>
/// Add a parameter that may be null
/// </summary>
/// <param name="name">The name of the parameter</param>
/// <param name="value">The value of the parameter</param>
public static void AddMaybeNull(this NpgsqlCommand cmd, string name, object? value) =>
cmd.Parameters.Add(new NpgsqlParameter($"@{name}", value == null ? DBNull.Value : value));
#endregion
}
}

View File

@ -1,5 +1,7 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore;
using Npgsql; using Npgsql;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
@ -8,93 +10,47 @@ using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data namespace JobsJobsJobs.Server.Data
{ {
/// <summary> /// <summary>
/// Extensions to the Connection type to support manipulation of profiles /// Extensions to JobsDbContext to support manipulation of profiles
/// </summary> /// </summary>
public static class ProfileExtensions public static class ProfileExtensions
{ {
/// <summary>
/// Populate a profile object from the given data reader
/// </summary>
/// <param name="rdr">The data reader from which values should be obtained</param>
/// <returns>The populated profile</returns>
private static Profile ToProfile(NpgsqlDataReader rdr)
{
var continentId = ContinentId.Parse(rdr.GetString("continent_id"));
return new Profile(CitizenId.Parse(rdr.GetString("citizen_id")), rdr.GetBoolean("seeking_employment"),
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
rdr.GetInstant("last_updated_on"),
rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience")))
{
Continent = new Continent(continentId, rdr.GetString("continent_name"))
};
}
/// <summary>
/// Populate a skill object from the given data reader
/// </summary>
/// <param name="rdr">The data reader from which values should be obtained</param>
/// <returns>The populated skill</returns>
private static Skill ToSkill(NpgsqlDataReader rdr) =>
new Skill(SkillId.Parse(rdr.GetString("id")), CitizenId.Parse(rdr.GetString("citizen_id")),
rdr.GetString("skill"), rdr.IsDBNull("notes") ? null : rdr.GetString("notes"));
/// <summary> /// <summary>
/// Retrieve an employment profile by a citizen ID /// Retrieve an employment profile by a citizen ID
/// </summary> /// </summary>
/// <param name="citizen">The ID of the citizen whose profile should be retrieved</param> /// <param name="citizenId">The ID of the citizen whose profile should be retrieved</param>
/// <returns>The profile, or null if it does not exist</returns> /// <returns>The profile, or null if it does not exist</returns>
public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen) public static async Task<Profile?> FindProfileByCitizen(this JobsDbContext db, CitizenId citizenId)
{ {
using var cmd = conn.CreateCommand(); var profile = await db.Profiles.AsNoTracking()
cmd.CommandText = .SingleOrDefaultAsync(p => p.Id == citizenId)
@"SELECT p.*, c.name AS continent_name .ConfigureAwait(false);
FROM profile p
INNER JOIN continent c ON p.continent_id = c.id
WHERE citizen_id = @id";
cmd.AddString("id", citizen.Id);
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); if (profile != null)
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null; {
return profile with
{
Continent = await db.FindContinentById(profile.ContinentId).ConfigureAwait(false),
Skills = (await db.FindSkillsByCitizen(citizenId).ConfigureAwait(false)).ToArray()
};
}
return null;
} }
/// <summary> /// <summary>
/// Save a profile /// Save a profile
/// </summary> /// </summary>
/// <param name="profile">The profile to be saved</param> /// <param name="profile">The profile to be saved</param>
public static async Task SaveProfile(this NpgsqlConnection conn, Profile profile) public static async Task SaveProfile(this JobsDbContext db, Profile profile)
{ {
using var cmd = conn.CreateCommand(); if (await db.Profiles.CountAsync(p => p.Id == profile.Id).ConfigureAwait(false) == 0)
cmd.CommandText = {
@"INSERT INTO profile ( await db.AddAsync(profile).ConfigureAwait(false);
citizen_id, seeking_employment, is_public, continent_id, region, remote_work, full_time, }
biography, last_updated_on, experience else
) VALUES ( {
@citizen_id, @seeking_employment, @is_public, @continent_id, @region, @remote_work, @full_time, db.Entry(profile).State = EntityState.Modified;
@biography, @last_updated_on, @experience }
) ON CONFLICT (citizen_id) DO UPDATE
SET seeking_employment = @seeking_employment,
is_public = @is_public,
continent_id = @continent_id,
region = @region,
remote_work = @remote_work,
full_time = @full_time,
biography = @biography,
last_updated_on = @last_updated_on,
experience = @experience
WHERE profile.citizen_id = excluded.citizen_id";
cmd.AddString("citizen_id", profile.Id);
cmd.AddBool("seeking_employment", profile.SeekingEmployment);
cmd.AddBool("is_public", profile.IsPublic);
cmd.AddString("continent_id", profile.ContinentId);
cmd.AddString("region", profile.Region);
cmd.AddBool("remote_work", profile.RemoteWork);
cmd.AddBool("full_time", profile.FullTime);
cmd.AddString("biography", profile.Biography.Text);
cmd.AddInstant("last_updated_on", profile.LastUpdatedOn);
cmd.AddMaybeNull("experience", profile.Experience);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
} }
/// <summary> /// <summary>
@ -102,45 +58,25 @@ namespace JobsJobsJobs.Server.Data
/// </summary> /// </summary>
/// <param name="citizenId">The ID of the citizen whose skills should be retrieved</param> /// <param name="citizenId">The ID of the citizen whose skills should be retrieved</param>
/// <returns>The skills defined for this citizen</returns> /// <returns>The skills defined for this citizen</returns>
public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this NpgsqlConnection conn, public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
CitizenId citizenId) await db.Skills.AsNoTracking()
{ .Where(s => s.CitizenId == citizenId)
using var cmd = conn.CreateCommand(); .ToListAsync().ConfigureAwait(false);
cmd.CommandText = "SELECT * FROM skill WHERE citizen_id = @citizen_id";
cmd.AddString("citizen_id", citizenId);
var result = new List<Skill>();
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
while (await rdr.ReadAsync().ConfigureAwait(false))
{
result.Add(ToSkill(rdr));
}
return result;
}
/// <summary> /// <summary>
/// Save a skill /// Save a skill
/// </summary> /// </summary>
/// <param name="skill">The skill to be saved</param> /// <param name="skill">The skill to be saved</param>
public static async Task SaveSkill(this NpgsqlConnection conn, Skill skill) public static async Task SaveSkill(this JobsDbContext db, Skill skill)
{ {
using var cmd = conn.CreateCommand(); if (await db.Skills.CountAsync(s => s.Id == skill.Id).ConfigureAwait(false) == 0)
cmd.CommandText = {
@"INSERT INTO skill ( await db.Skills.AddAsync(skill).ConfigureAwait(false);
id, citizen_id, skill, notes }
) VALUES ( else
@id, @citizen_id, @skill, @notes {
) ON CONFLICT (id) DO UPDATE db.Entry(skill).State = EntityState.Modified;
SET skill = @skill, }
notes = @notes
WHERE skill.id = excluded.id";
cmd.AddString("id", skill.Id);
cmd.AddString("citizen_id", skill.CitizenId);
cmd.AddString("skill", skill.Description);
cmd.AddMaybeNull("notes", skill.Notes);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
} }
/// <summary> /// <summary>
@ -148,50 +84,29 @@ namespace JobsJobsJobs.Server.Data
/// </summary> /// </summary>
/// <param name="citizenId">The ID of the citizen to whom the skills belong</param> /// <param name="citizenId">The ID of the citizen to whom the skills belong</param>
/// <param name="ids">The IDs of their current skills</param> /// <param name="ids">The IDs of their current skills</param>
public static async Task DeleteMissingSkills(this NpgsqlConnection conn, CitizenId citizenId, public static async Task DeleteMissingSkills(this JobsDbContext db, CitizenId citizenId,
IEnumerable<SkillId> ids) IEnumerable<SkillId> ids)
{ {
if (!ids.Any()) return; if (!ids.Any()) return;
var count = 0; db.Skills.RemoveRange(await db.Skills.AsNoTracking()
using var cmd = conn.CreateCommand(); .Where(s => !ids.Contains(s.Id)).ToListAsync()
cmd.CommandText = new StringBuilder("DELETE FROM skill WHERE citizen_id = @citizen_id AND id NOT IN (") .ConfigureAwait(false));
.Append(string.Join(", ", ids.Select(_ => $"@id{count++}").ToArray()))
.Append(')')
.ToString();
cmd.AddString("citizen_id", citizenId);
count = 0;
foreach (var id in ids) cmd.AddString($"id{count++}", id);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Get a count of the citizens with profiles /// Get a count of the citizens with profiles
/// </summary> /// </summary>
/// <returns>The number of citizens with profiles</returns> /// <returns>The number of citizens with profiles</returns>
public static async Task<long> CountProfiles(this NpgsqlConnection conn) public static async Task<int> CountProfiles(this JobsDbContext db) =>
{ await db.Profiles.CountAsync().ConfigureAwait(false);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(citizen_id) FROM profile";
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
return result == null ? 0L : (long)result;
}
/// <summary> /// <summary>
/// Count the skills for the given citizen /// Count the skills for the given citizen
/// </summary> /// </summary>
/// <param name="citizenId">The ID of the citizen whose skills should be counted</param> /// <param name="citizenId">The ID of the citizen whose skills should be counted</param>
/// <returns>The count of skills for the given citizen</returns> /// <returns>The count of skills for the given citizen</returns>
public static async Task<long> CountSkills(this NpgsqlConnection conn, CitizenId citizenId) public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
{ await db.Skills.CountAsync(s => s.CitizenId == citizenId).ConfigureAwait(false);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(id) FROM skill WHERE citizen_id = @citizen_id";
cmd.AddString("citizen_id", citizenId);
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
return result == null ? 0L : (long)result;
}
} }
} }

View File

@ -10,6 +10,8 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
<PackageReference Include="Npgsql" Version="5.0.1.1" /> <PackageReference Include="Npgsql" Version="5.0.1.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="5.0.1" />
<PackageReference Include="Npgsql.NodaTime" Version="5.0.1.1" /> <PackageReference Include="Npgsql.NodaTime" Version="5.0.1.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />
</ItemGroup> </ItemGroup>

View File

@ -1,9 +1,11 @@
using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -30,7 +32,11 @@ namespace JobsJobsJobs.Server
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
// TODO: configure JSON serialization for NodaTime // TODO: configure JSON serialization for NodaTime
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb"))); services.AddDbContext<JobsDbContext>(options =>
{
options.UseNpgsql(Configuration.GetConnectionString("JobsDb"), o => o.UseNodaTime());
options.LogTo(System.Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
});
services.AddSingleton<IClock>(SystemClock.Instance); services.AddSingleton<IClock>(SystemClock.Instance);
services.AddLogging(); services.AddLogging();
services.AddControllersWithViews(); services.AddControllersWithViews();

View File

@ -3,5 +3,5 @@
/// <summary> /// <summary>
/// A transport mechanism to send counts across the wire via JSON /// A transport mechanism to send counts across the wire via JSON
/// </summary> /// </summary>
public record Count(long Value); public record Count(int Value);
} }

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace JobsJobsJobs.Shared.Api namespace JobsJobsJobs.Shared.Api
{ {
@ -74,7 +75,13 @@ namespace JobsJobsJobs.Shared.Api
RemoteWork = profile.RemoteWork, RemoteWork = profile.RemoteWork,
FullTime = profile.FullTime, FullTime = profile.FullTime,
Biography = profile.Biography.Text, Biography = profile.Biography.Text,
Experience = profile.Experience?.Text ?? "" Experience = profile.Experience?.Text ?? "",
Skills = profile.Skills.Select(s => new SkillForm
{
Id = s.Id.ToString(),
Description = s.Description,
Notes = s.Notes
}).ToList()
}; };
} }
} }

View File

@ -1,4 +1,5 @@
using NodaTime; using NodaTime;
using System;
namespace JobsJobsJobs.Shared namespace JobsJobsJobs.Shared
{ {
@ -21,5 +22,10 @@ namespace JobsJobsJobs.Shared
/// Navigation property for continent /// Navigation property for continent
/// </summary> /// </summary>
public Continent? Continent { get; set; } public Continent? Continent { get; set; }
/// <summary>
/// Convenience property for skills associated with a profile
/// </summary>
public Skill[] Skills { get; set; } = Array.Empty<Skill>();
} }
} }

View File

@ -12,5 +12,15 @@ namespace JobsJobsJobs.Shared
/// </summary> /// </summary>
/// <returns>A new success report ID</returns> /// <returns>A new success report ID</returns>
public static async Task<SuccessId> Create() => new SuccessId(await ShortId.Create()); public static async Task<SuccessId> Create() => new SuccessId(await ShortId.Create());
/// <summary>
/// Attempt to create a success report ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The success report ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid success report ID</exception>
public static SuccessId Parse(string id) => new SuccessId(ShortId.Parse(id));
public override string ToString() => Id.ToString();
} }
} }