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:
parent
97b3de1cea
commit
ef12da01dc
|
@ -3,34 +3,4 @@
|
|||
@inject NavigationManager nav
|
||||
@inject AppState state
|
||||
|
||||
<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\"?)";
|
||||
}
|
||||
}
|
||||
}
|
||||
<p>@Message</p>
|
||||
|
|
40
src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs
Normal file
40
src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs
Normal 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\"?)";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
@page "/citizen/dashboard"
|
||||
@inject HttpClient http
|
||||
@inject AppState state
|
||||
|
||||
<h3>Welcome, @State.User!.Name!</h3>
|
||||
<h3>Welcome, @state.User!.Name!</h3>
|
||||
|
||||
@if (RetrievingData)
|
||||
{
|
||||
|
@ -8,11 +10,12 @@
|
|||
}
|
||||
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 @SkillCount skill@(SkillCount != 1 ? "s" : "").
|
||||
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
|
||||
|
@ -27,15 +30,5 @@ else
|
|||
<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>
|
||||
</ErrorList>
|
||||
}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Domain = JobsJobsJobs.Shared;
|
||||
|
||||
namespace JobsJobsJobs.Client.Pages.Citizen
|
||||
{
|
||||
|
@ -20,46 +19,27 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
|||
/// <summary>
|
||||
/// The user's profile
|
||||
/// </summary>
|
||||
private Profile? Profile { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// The number of skills in the user's profile
|
||||
/// </summary>
|
||||
private long SkillCount { get; set; } = 0L;
|
||||
private Domain.Profile? Profile { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// The number of profiles
|
||||
/// </summary>
|
||||
private long ProfileCount { get; set; } = 0L;
|
||||
private int ProfileCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error messages from data access
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
if (State.User != null)
|
||||
if (state.User != null)
|
||||
{
|
||||
ServerApi.SetJwt(Http, State);
|
||||
var profileTask = ServerApi.RetrieveProfile(Http, State);
|
||||
var profileCountTask = ServerApi.RetrieveOne<Count>(Http, "profile/count");
|
||||
var skillCountTask = ServerApi.RetrieveOne<Count>(Http, "profile/skill-count");
|
||||
ServerApi.SetJwt(http, state);
|
||||
var profileTask = ServerApi.RetrieveProfile(http, state);
|
||||
var profileCountTask = ServerApi.RetrieveOne<Count>(http, "profile/count");
|
||||
|
||||
await Task.WhenAll(profileTask, profileCountTask, skillCountTask);
|
||||
await Task.WhenAll(profileTask, profileCountTask);
|
||||
|
||||
if (profileTask.Result.IsOk)
|
||||
{
|
||||
|
@ -79,18 +59,8 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
|||
ErrorMessages.Add(profileCountTask.Result.Error);
|
||||
}
|
||||
|
||||
if (skillCountTask.Result.IsOk)
|
||||
{
|
||||
SkillCount = skillCountTask.Result.Ok?.Value ?? 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessages.Add(skillCountTask.Result.Error);
|
||||
}
|
||||
|
||||
RetrievingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
@page "/citizen/profile"
|
||||
@inject HttpClient http
|
||||
@inject AppState state
|
||||
@inject IToastService toast
|
||||
|
||||
<h3>Employment Profile</h3>
|
||||
|
||||
|
@ -14,12 +17,12 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@ProfileForm" OnValidSubmit="@SaveProfile">
|
||||
<EditForm Model=@ProfileForm OnValidSubmit=@SaveProfile>
|
||||
<DataAnnotationsValidator />
|
||||
<div class="form-row">
|
||||
<div class="col">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,22 +31,22 @@
|
|||
<div class="col col-xs-12 col-sm-6 col-md-4">
|
||||
<div class="form-group">
|
||||
<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>– Select –</option>
|
||||
@foreach (var (id, name) in Continents)
|
||||
{
|
||||
<option value="@id">@name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => ProfileForm.ContinentId)" />
|
||||
<ValidationMessage For=@(() => ProfileForm.ContinentId) />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-md-8">
|
||||
<div class="form-group">
|
||||
<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." />
|
||||
<ValidationMessage For="@(() => ProfileForm.Region)" />
|
||||
<ValidationMessage For=@(() => ProfileForm.Region) />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -51,21 +54,21 @@
|
|||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="bio" class="jjj-required">Professional Biography</label>
|
||||
<MarkdownEditor Id="bio" @bind-Text="@ProfileForm.Biography" />
|
||||
<ValidationMessage For="@(() => ProfileForm.Biography)" />
|
||||
<MarkdownEditor Id="bio" @bind-Text=@ProfileForm.Biography />
|
||||
<ValidationMessage For=@(() => ProfileForm.Biography) />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-12 col-md-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -73,11 +76,11 @@
|
|||
<hr>
|
||||
<h4>
|
||||
Skills
|
||||
<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>
|
||||
@foreach (var skill in ProfileForm.Skills)
|
||||
{
|
||||
<SkillEdit Skill="@skill" OnRemove="@RemoveSkill" />
|
||||
<SkillEdit Skill=@skill OnRemove=@RemoveSkill />
|
||||
}
|
||||
<hr>
|
||||
<h4>Experience</h4>
|
||||
|
@ -88,13 +91,13 @@
|
|||
</p>
|
||||
<div class="form-row">
|
||||
<div class="col">
|
||||
<MarkdownEditor Id="experience" @bind-Text="@ProfileForm.Experience" />
|
||||
<MarkdownEditor Id="experience" @bind-Text=@ProfileForm.Experience />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="col">
|
||||
<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">
|
||||
Allow my profile to be searched publicly (outside NA Social)
|
||||
</label>
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
using Blazored.Toast.Services;
|
||||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
@ -40,32 +38,13 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
|||
/// </summary>
|
||||
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()
|
||||
{
|
||||
ServerApi.SetJwt(Http, State);
|
||||
var continentTask = ServerApi.RetrieveMany<Continent>(Http, "continent/all");
|
||||
var profileTask = ServerApi.RetrieveProfile(Http, State);
|
||||
var skillTask = ServerApi.RetrieveMany<Skill>(Http, "profile/skills");
|
||||
ServerApi.SetJwt(http, state);
|
||||
var continentTask = ServerApi.RetrieveMany<Continent>(http, "continent/all");
|
||||
var profileTask = ServerApi.RetrieveProfile(http, state);
|
||||
|
||||
await Task.WhenAll(continentTask, profileTask, skillTask);
|
||||
await Task.WhenAll(continentTask, profileTask);
|
||||
|
||||
if (continentTask.Result.IsOk)
|
||||
{
|
||||
|
@ -81,28 +60,11 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
|||
ProfileForm = (profileTask.Result.Ok == null)
|
||||
? new ProfileForm()
|
||||
: 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();
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessages.Add(skillTask.Result.Error);
|
||||
ErrorMessages.Add(profileTask.Result.Error);
|
||||
}
|
||||
|
||||
AllLoaded = true;
|
||||
|
@ -132,16 +94,16 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
|||
.ToList();
|
||||
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)
|
||||
{
|
||||
Toasts.ShowSuccess("Profile Saved Successfully");
|
||||
toast.ShowSuccess("Profile Saved Successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await res.Content.ReadAsStringAsync();
|
||||
if (!string.IsNullOrEmpty(error)) error = $"- {error}";
|
||||
Toasts.ShowError($"{(int)res.StatusCode} {error}");
|
||||
toast.ShowError($"{(int)res.StatusCode} {error}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
Do you not understand the terms in the paragraph above? No worries; just head over to
|
||||
<a href="https://noagendashow.net">
|
||||
The Best Podcast in the Universe
|
||||
</a> <em><a class="audio" @onclick="PlayTrue">(it’s true!)</a></em> and find out what you’re missing.
|
||||
</a> <em><a class="audio" @onclick=@PlayTrue>(it’s true!)</a></em> and find out what you’re missing.
|
||||
</p>
|
||||
<audio id="itstrue">
|
||||
<source src="/audio/thats-true.mp3">
|
||||
|
|
15
src/JobsJobsJobs/Client/Pages/Profile/View.razor
Normal file
15
src/JobsJobsJobs/Client/Pages/Profile/View.razor
Normal 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>
|
||||
}
|
58
src/JobsJobsJobs/Client/Pages/Profile/View.razor.cs
Normal file
58
src/JobsJobsJobs/Client/Pages/Profile/View.razor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -64,8 +64,13 @@ namespace JobsJobsJobs.Client
|
|||
/// </summary>
|
||||
/// <param name="http">The HTTP client whose authentication header should be set</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)
|
||||
{
|
||||
if (state.User != null)
|
||||
{
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log on a user with the authorization code received from No Agenda Social
|
||||
|
|
22
src/JobsJobsJobs/Client/Shared/ErrorList.razor
Normal file
22
src/JobsJobsJobs/Client/Shared/ErrorList.razor
Normal 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!;
|
||||
}
|
|
@ -4,7 +4,6 @@ using JobsJobsJobs.Shared.Api;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
|
@ -27,17 +26,17 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
private readonly IClock _clock;
|
||||
|
||||
/// <summary>
|
||||
/// The data connection to use for this request
|
||||
/// The data context to use for this request
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection _db;
|
||||
private readonly JobsDbContext _db;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="config">The authorization configuration section</param>
|
||||
/// <param name="clock">The NodaTime clock instance</param>
|
||||
/// <param name="db">The data connection to use for this request</param>
|
||||
public CitizenController(IConfiguration config, IClock clock, NpgsqlConnection db)
|
||||
/// <param name="db">The data context to use for this request</param>
|
||||
public CitizenController(IConfiguration config, IClock clock, JobsDbContext db)
|
||||
{
|
||||
_config = config.GetSection("Auth");
|
||||
_clock = clock;
|
||||
|
@ -56,7 +55,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
var account = accountResult.Ok;
|
||||
var now = _clock.GetCurrentInstant();
|
||||
|
||||
await _db.OpenAsync();
|
||||
var citizen = await _db.FindCitizenByNAUser(account.Username);
|
||||
if (citizen == null)
|
||||
{
|
||||
|
@ -71,13 +69,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
DisplayName = account.DisplayName,
|
||||
LastSeenOn = now
|
||||
};
|
||||
await _db.UpdateCitizenOnLogOn(citizen);
|
||||
_db.UpdateCitizen(citizen);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Step 3 - Generate JWT
|
||||
var jwt = Auth.CreateJwt(citizen, _config);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using JobsJobsJobs.Server.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Npgsql;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
|
@ -15,24 +14,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
public class ContinentController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The database connection to use for this request
|
||||
/// The data context to use for this request
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection _db;
|
||||
private readonly JobsDbContext _db;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="db">The database connection to use for this request</param>
|
||||
public ContinentController(NpgsqlConnection db)
|
||||
/// <param name="db">The data context to use for this request</param>
|
||||
public ContinentController(JobsDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("all")]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(await _db.AllContinents());
|
||||
}
|
||||
public async Task<IActionResult> All() =>
|
||||
Ok(await _db.AllContinents());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,8 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
|
@ -24,9 +20,9 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
public class ProfileController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The database connection
|
||||
/// The data context
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection _db;
|
||||
private readonly JobsDbContext _db;
|
||||
|
||||
/// <summary>
|
||||
/// The NodaTime clock instance
|
||||
|
@ -36,8 +32,8 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="db">The database connection to use for this request</param>
|
||||
public ProfileController(NpgsqlConnection db, IClock clock)
|
||||
/// <param name="db">The data context to use for this request</param>
|
||||
public ProfileController(JobsDbContext db, IClock clock)
|
||||
{
|
||||
_db = db;
|
||||
_clock = clock;
|
||||
|
@ -48,10 +44,12 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
/// </summary>
|
||||
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("")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
|
||||
return profile == null ? NoContent() : Ok(profile);
|
||||
}
|
||||
|
@ -59,9 +57,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
[HttpPost("save")]
|
||||
public async Task<IActionResult> Save(ProfileForm form)
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
var txn = await _db.BeginTransactionAsync();
|
||||
|
||||
// Profile
|
||||
var existing = await _db.FindProfileByCitizen(CurrentCitizenId);
|
||||
var profile = existing == null
|
||||
|
@ -93,29 +88,27 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||
foreach (var skill in skills) await _db.SaveSkill(skill);
|
||||
await _db.DeleteMissingSkills(CurrentCitizenId, skills.Select(s => s.Id));
|
||||
|
||||
await txn.CommitAsync();
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("skills")]
|
||||
public async Task<IActionResult> GetSkills()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
|
||||
}
|
||||
public async Task<IActionResult> GetSkills() =>
|
||||
Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
|
||||
|
||||
[HttpGet("count")]
|
||||
public async Task<IActionResult> GetProfileCount()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(new Count(await _db.CountProfiles()));
|
||||
}
|
||||
public async Task<IActionResult> GetProfileCount() =>
|
||||
Ok(new Count(await _db.CountProfiles()));
|
||||
|
||||
[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();
|
||||
return Ok(new Count(await _db.CountSkills(CurrentCitizenId)));
|
||||
var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
|
||||
return profile == null ? NotFound() : Ok(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,80 +1,46 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the NpgslConnection type supporting the manipulation of citizens
|
||||
/// Extensions to JobsDbContext supporting the manipulation of citizens
|
||||
/// </summary>
|
||||
public static class CitizenExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a citizen object from the given data reader
|
||||
/// Retrieve a citizen by their Jobs, Jobs, Jobs ID
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which the values should be obtained</param>
|
||||
/// <returns>A populated citizen</returns>
|
||||
private static Citizen ToCitizen(NpgsqlDataReader rdr) =>
|
||||
new Citizen(CitizenId.Parse(rdr.GetString("id")), rdr.GetString("na_user"), rdr.GetString("display_name"),
|
||||
rdr.GetString("profile_url"), rdr.GetInstant("joined_on"), rdr.GetInstant("last_seen_on"));
|
||||
/// <param name="citizenId">The ID of the citizen to retrieve</param>
|
||||
/// <returns>The citizen, or null if not found</returns>
|
||||
public static async Task<Citizen?> FindCitizenById(this JobsDbContext db, CitizenId citizenId) =>
|
||||
await db.Citizens.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == citizenId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a citizen by their No Agenda Social user name
|
||||
/// </summary>
|
||||
/// <param name="naUser">The NAS user name</param>
|
||||
/// <returns>The citizen, or null if not found</returns>
|
||||
public static async Task<Citizen?> FindCitizenByNAUser(this NpgsqlConnection conn, string naUser)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM citizen WHERE na_user = @na_user";
|
||||
cmd.AddString("na_user", naUser);
|
||||
|
||||
using NpgsqlDataReader rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
if (await rdr.ReadAsync().ConfigureAwait(false)) return ToCitizen(rdr);
|
||||
|
||||
return null;
|
||||
}
|
||||
public static async Task<Citizen?> FindCitizenByNAUser(this JobsDbContext db, string naUser) =>
|
||||
await db.Citizens.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.NaUser == naUser)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Add a citizen
|
||||
/// </summary>
|
||||
/// <param name="citizen">The citizen to be added</param>
|
||||
public static async Task AddCitizen(this NpgsqlConnection conn, Citizen 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);
|
||||
}
|
||||
public static async Task AddCitizen(this JobsDbContext db, Citizen citizen) =>
|
||||
await db.Citizens.AddAsync(citizen);
|
||||
|
||||
/// <summary>
|
||||
/// Update a citizen after they have logged on (update last seen, sync display name)
|
||||
/// </summary>
|
||||
/// <param name="citizen">The updated citizen</param>
|
||||
public static async Task UpdateCitizenOnLogOn(this NpgsqlConnection conn, Citizen citizen)
|
||||
{
|
||||
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);
|
||||
}
|
||||
public static void UpdateCitizen(this JobsDbContext db, Citizen citizen) =>
|
||||
db.Entry(citizen).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
|
@ -10,31 +11,21 @@ namespace JobsJobsJobs.Server.Data
|
|||
/// </summary>
|
||||
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>
|
||||
/// Retrieve all continents
|
||||
/// </summary>
|
||||
/// <returns>All continents</returns>
|
||||
public static async Task<IEnumerable<Continent>> AllContinents(this NpgsqlConnection conn)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM continent ORDER BY name";
|
||||
public static async Task<IEnumerable<Continent>> AllContinents(this JobsDbContext db) =>
|
||||
await db.Continents.AsNoTracking().OrderBy(c => c.Name).ToListAsync().ConfigureAwait(false);
|
||||
|
||||
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
var continents = new List<Continent>();
|
||||
while (await rdr.ReadAsync())
|
||||
{
|
||||
continents.Add(ToContinent(rdr));
|
||||
}
|
||||
|
||||
return continents;
|
||||
}
|
||||
/// <summary>
|
||||
/// Retrieve a continent by its ID
|
||||
/// </summary>
|
||||
/// <param name="continentId">The ID of the continent to retrieve</param>
|
||||
/// <returns>The continent matching the ID</returns>
|
||||
public static async Task<Continent> FindContinentById(this JobsDbContext db, ContinentId continentId) =>
|
||||
await db.Continents.AsNoTracking()
|
||||
.SingleAsync(c => c.Id == continentId)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
|
53
src/JobsJobsJobs/Server/Data/Converters.cs
Normal file
53
src/JobsJobsJobs/Server/Data/Converters.cs
Normal 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));
|
||||
}
|
||||
}
|
111
src/JobsJobsJobs/Server/Data/JobsDbContext.cs
Normal file
111
src/JobsJobsJobs/Server/Data/JobsDbContext.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
@ -8,93 +10,47 @@ using System.Threading.Tasks;
|
|||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the Connection type to support manipulation of profiles
|
||||
/// Extensions to JobsDbContext to support manipulation of profiles
|
||||
/// </summary>
|
||||
public static class ProfileExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a profile object from the given data reader
|
||||
/// Retrieve an employment profile by a citizen ID
|
||||
/// </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)
|
||||
/// <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>
|
||||
public static async Task<Profile?> FindProfileByCitizen(this JobsDbContext db, CitizenId citizenId)
|
||||
{
|
||||
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")))
|
||||
var profile = await db.Profiles.AsNoTracking()
|
||||
.SingleOrDefaultAsync(p => p.Id == citizenId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
Continent = new Continent(continentId, rdr.GetString("continent_name"))
|
||||
return profile with
|
||||
{
|
||||
Continent = await db.FindContinentById(profile.ContinentId).ConfigureAwait(false),
|
||||
Skills = (await db.FindSkillsByCitizen(citizenId).ConfigureAwait(false)).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Retrieve an employment profile by a citizen ID
|
||||
/// </summary>
|
||||
/// <param name="citizen">The ID of the citizen whose profile should be retrieved</param>
|
||||
/// <returns>The profile, or null if it does not exist</returns>
|
||||
public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"SELECT p.*, c.name AS continent_name
|
||||
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);
|
||||
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a profile
|
||||
/// </summary>
|
||||
/// <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();
|
||||
cmd.CommandText =
|
||||
@"INSERT INTO profile (
|
||||
citizen_id, seeking_employment, is_public, continent_id, region, remote_work, full_time,
|
||||
biography, last_updated_on, experience
|
||||
) VALUES (
|
||||
@citizen_id, @seeking_employment, @is_public, @continent_id, @region, @remote_work, @full_time,
|
||||
@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);
|
||||
if (await db.Profiles.CountAsync(p => p.Id == profile.Id).ConfigureAwait(false) == 0)
|
||||
{
|
||||
await db.AddAsync(profile).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Entry(profile).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -102,45 +58,25 @@ namespace JobsJobsJobs.Server.Data
|
|||
/// </summary>
|
||||
/// <param name="citizenId">The ID of the citizen whose skills should be retrieved</param>
|
||||
/// <returns>The skills defined for this citizen</returns>
|
||||
public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this NpgsqlConnection conn,
|
||||
CitizenId citizenId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
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;
|
||||
}
|
||||
public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
|
||||
await db.Skills.AsNoTracking()
|
||||
.Where(s => s.CitizenId == citizenId)
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Save a skill
|
||||
/// </summary>
|
||||
/// <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();
|
||||
cmd.CommandText =
|
||||
@"INSERT INTO skill (
|
||||
id, citizen_id, skill, notes
|
||||
) VALUES (
|
||||
@id, @citizen_id, @skill, @notes
|
||||
) ON CONFLICT (id) DO UPDATE
|
||||
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);
|
||||
if (await db.Skills.CountAsync(s => s.Id == skill.Id).ConfigureAwait(false) == 0)
|
||||
{
|
||||
await db.Skills.AddAsync(skill).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Entry(skill).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -148,50 +84,29 @@ namespace JobsJobsJobs.Server.Data
|
|||
/// </summary>
|
||||
/// <param name="citizenId">The ID of the citizen to whom the skills belong</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)
|
||||
{
|
||||
if (!ids.Any()) return;
|
||||
|
||||
var count = 0;
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = new StringBuilder("DELETE FROM skill WHERE citizen_id = @citizen_id AND id NOT IN (")
|
||||
.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);
|
||||
db.Skills.RemoveRange(await db.Skills.AsNoTracking()
|
||||
.Where(s => !ids.Contains(s.Id)).ToListAsync()
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a count of the citizens with profiles
|
||||
/// </summary>
|
||||
/// <returns>The number of citizens with profiles</returns>
|
||||
public static async Task<long> CountProfiles(this NpgsqlConnection conn)
|
||||
{
|
||||
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;
|
||||
}
|
||||
public static async Task<int> CountProfiles(this JobsDbContext db) =>
|
||||
await db.Profiles.CountAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Count the skills for the given citizen
|
||||
/// </summary>
|
||||
/// <param name="citizenId">The ID of the citizen whose skills should be counted</param>
|
||||
/// <returns>The count of skills for the given citizen</returns>
|
||||
public static async Task<long> CountSkills(this NpgsqlConnection conn, CitizenId citizenId)
|
||||
{
|
||||
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;
|
||||
}
|
||||
public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
|
||||
await db.Skills.CountAsync(s => s.CitizenId == citizenId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||
<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="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
using JobsJobsJobs.Server.Data;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpsPolicy;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
@ -30,7 +32,11 @@ namespace JobsJobsJobs.Server
|
|||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// 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.AddLogging();
|
||||
services.AddControllersWithViews();
|
||||
|
|
|
@ -3,5 +3,5 @@
|
|||
/// <summary>
|
||||
/// A transport mechanism to send counts across the wire via JSON
|
||||
/// </summary>
|
||||
public record Count(long Value);
|
||||
public record Count(int Value);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
|
@ -74,7 +75,13 @@ namespace JobsJobsJobs.Shared.Api
|
|||
RemoteWork = profile.RemoteWork,
|
||||
FullTime = profile.FullTime,
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using NodaTime;
|
||||
using System;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
|
@ -21,5 +22,10 @@ namespace JobsJobsJobs.Shared
|
|||
/// Navigation property for continent
|
||||
/// </summary>
|
||||
public Continent? Continent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property for skills associated with a profile
|
||||
/// </summary>
|
||||
public Skill[] Skills { get; set; } = Array.Empty<Skill>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,5 +12,15 @@ namespace JobsJobsJobs.Shared
|
|||
/// </summary>
|
||||
/// <returns>A new success report ID</returns>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user