WIP on implementing NodaTime

This commit is contained in:
Daniel J. Summers 2020-12-16 23:10:20 -05:00
parent 404e314f97
commit c782f5aac4
16 changed files with 109 additions and 72 deletions

View File

@ -9,6 +9,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Client", "Jobs
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Shared", "JobsJobsJobs\Shared\JobsJobsJobs.Shared.csproj", "{AE329284-47DA-4E76-B542-47489B271130}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Shared", "JobsJobsJobs\Shared\JobsJobsJobs.Shared.csproj", "{AE329284-47DA-4E76-B542-47489B271130}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50B51580-9F09-41E2-BC78-DAD38C37B583}"
ProjectSection(SolutionItems) = preProject
database\tables.sql = database\tables.sql
EndProjectSection
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU

View File

@ -1,4 +1,5 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using System;
namespace JobsJobsJobs.Client namespace JobsJobsJobs.Client
{ {
@ -12,16 +13,40 @@ namespace JobsJobsJobs.Client
/// </summary> /// </summary>
public class AppState public class AppState
{ {
public event Action OnChange = () => { };
private UserInfo? _user = null;
/// <summary> /// <summary>
/// The information of the currently logged-in user /// The information of the currently logged-in user
/// </summary> /// </summary>
public UserInfo? User { get; set; } = null; public UserInfo? User
{
get => _user;
set
{
_user = value;
NotifyChanged();
}
}
private string _jwt = "";
/// <summary> /// <summary>
/// The JSON Web Token (JWT) for the currently logged-on user /// The JSON Web Token (JWT) for the currently logged-on user
/// </summary> /// </summary>
public string Jwt { get; set; } = ""; public string Jwt
{
get => _jwt;
set
{
_jwt = value;
NotifyChanged();
}
}
public AppState() { } public AppState() { }
private void NotifyChanged() => OnChange.Invoke();
} }
} }

View File

@ -2,13 +2,20 @@
@inject HttpClient http @inject HttpClient http
@inject AppState state @inject AppState state
<h3>Dashboard</h3> <h3>Welcome, @state.User!.Name!</h3>
<p>Here's the dashboard, homie...</p> @if (retrievingProfile)
@if (hasProfile != null)
{ {
<p>Has profile? @hasProfile</p> <p>Retrieving your employment profile...</p>
}
else if (profile != null)
{
<p>Your employment profile was last updated @profile.LastUpdatedOn</p>
}
else
{
<p>You do not have an employment profile established; click &ldquo;Profile&rdquo;* in the menu to get started!</p>
<p><em>* Once it's there...</em></p>
} }
@if (errorMessage != null) @if (errorMessage != null)
@ -17,21 +24,22 @@
} }
@code { @code {
bool? hasProfile = null; bool retrievingProfile = true;
Profile? profile = null;
string? errorMessage = null; string? errorMessage = null;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (state.User != null) if (state.User != null)
{ {
var profile = await ServerApi.RetrieveProfile(http, state); var profileResult = await ServerApi.RetrieveProfile(http, state);
if (profile.IsOk) if (profileResult.IsOk)
{ {
hasProfile = profile.Ok != null; profile = profileResult.Ok;
} }
else else
{ {
errorMessage = profile.Error; errorMessage = profileResult.Error;
} }
} }
} }

View File

@ -26,26 +26,32 @@
else else
{ {
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="dashboard"> <NavLink class="nav-link" href="/citizen/dashboard">
<span class="oi oi-dashboard" aria-hidden="true"></span> Dashboard <span class="oi oi-dashboard" aria-hidden="true"></span> Dashboard
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="/citizen/profile">
<span class="oi oi-spreadsheet" aria-hidden="true"></span> Profile
</NavLink>
</li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="counter"> <NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Log Off <span class="oi oi-plus" aria-hidden="true"></span> Log Off
</NavLink> </NavLink>
</li> </li>
} }
<li class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>
</li>
</ul> </ul>
</div> </div>
@code { @code {
protected override void OnInitialized()
{
base.OnInitialized();
state.OnChange += StateHasChanged;
}
/// <summary> /// <summary>
/// The client ID for Jobs, Jobs, Jobs at No Agenda Social /// The client ID for Jobs, Jobs, Jobs at No Agenda Social
/// </summary> /// </summary>

View File

@ -3,6 +3,7 @@ using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api; using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using NodaTime;
using Npgsql; using Npgsql;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -14,11 +15,14 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
{ {
private readonly IConfigurationSection _config; private readonly IConfigurationSection _config;
private readonly IClock _clock;
private readonly NpgsqlConnection _db; private readonly NpgsqlConnection _db;
public CitizenController(IConfiguration config, NpgsqlConnection db) public CitizenController(IConfiguration config, IClock clock, NpgsqlConnection db)
{ {
_config = config.GetSection("Auth"); _config = config.GetSection("Auth");
_clock = clock;
_db = db; _db = db;
} }
@ -32,7 +36,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
// Step 2 - Find / establish Jobs, Jobs, Jobs account // Step 2 - Find / establish Jobs, Jobs, Jobs account
var account = accountResult.Ok; var account = accountResult.Ok;
var now = Milliseconds.Now(); var now = _clock.GetCurrentInstant();
await _db.OpenAsync(); await _db.OpenAsync();
var citizen = await _db.FindCitizenByNAUser(account.Username); var citizen = await _db.FindCitizenByNAUser(account.Username);
@ -47,7 +51,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
citizen = citizen with citizen = citizen with
{ {
DisplayName = account.DisplayName, DisplayName = account.DisplayName,
LastSeenOn = Milliseconds.Now() LastSeenOn = now
}; };
await _db.UpdateCitizenOnLogOn(citizen); await _db.UpdateCitizenOnLogOn(citizen);
} }

View File

@ -20,7 +20,7 @@ namespace JobsJobsJobs.Server.Data
/// <returns>A populated citizen</returns> /// <returns>A populated citizen</returns>
private static Citizen ToCitizen(NpgsqlDataReader rdr) => private static Citizen ToCitizen(NpgsqlDataReader rdr) =>
new Citizen(CitizenId.Parse(rdr.GetString("id")), rdr.GetString("na_user"), rdr.GetString("display_name"), new Citizen(CitizenId.Parse(rdr.GetString("id")), rdr.GetString("na_user"), rdr.GetString("display_name"),
rdr.GetString("profile_url"), rdr.GetMilliseconds("joined_on"), rdr.GetMilliseconds("last_seen_on")); rdr.GetString("profile_url"), rdr.GetInstant("joined_on"), rdr.GetInstant("last_seen_on"));
/// <summary> /// <summary>
/// Retrieve a citizen by their No Agenda Social user name /// Retrieve a citizen by their No Agenda Social user name
@ -58,8 +58,8 @@ namespace JobsJobsJobs.Server.Data
cmd.Parameters.Add(new NpgsqlParameter("@na_user", citizen.NaUser)); cmd.Parameters.Add(new NpgsqlParameter("@na_user", citizen.NaUser));
cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName)); cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName));
cmd.Parameters.Add(new NpgsqlParameter("@profile_url", citizen.ProfileUrl)); cmd.Parameters.Add(new NpgsqlParameter("@profile_url", citizen.ProfileUrl));
cmd.Parameters.Add(new NpgsqlParameter("@joined_on", citizen.JoinedOn.Millis)); cmd.Parameters.Add(new NpgsqlParameter("@joined_on", citizen.JoinedOn));
cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn.Millis)); cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn));
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
} }
@ -78,7 +78,7 @@ namespace JobsJobsJobs.Server.Data
WHERE id = @id"; WHERE id = @id";
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString())); cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName)); cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName));
cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn.Millis)); cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn));
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
} }

View File

@ -1,4 +1,5 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using NodaTime;
using Npgsql; using Npgsql;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -19,6 +20,14 @@ namespace JobsJobsJobs.Server.Data
/// <returns>The specified field as a boolean</returns> /// <returns>The specified field as a boolean</returns>
public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name)); 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> /// <summary>
/// Get a 64-bit integer by its name /// Get a 64-bit integer by its name
/// </summary> /// </summary>
@ -26,14 +35,6 @@ namespace JobsJobsJobs.Server.Data
/// <returns>The specified field as a 64-bit integer</returns> /// <returns>The specified field as a 64-bit integer</returns>
public static long GetInt64(this NpgsqlDataReader rdr, string name) => rdr.GetInt64(rdr.GetOrdinal(name)); public static long GetInt64(this NpgsqlDataReader rdr, string name) => rdr.GetInt64(rdr.GetOrdinal(name));
/// <summary>
/// Get milliseconds by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as milliseconds</param>
/// <returns>The specified field as milliseconds</returns>
public static Milliseconds GetMilliseconds(this NpgsqlDataReader rdr, string name) =>
new Milliseconds(rdr.GetInt64(name));
/// <summary> /// <summary>
/// Get a string by its name /// Get a string by its name
/// </summary> /// </summary>

View File

@ -23,7 +23,7 @@ namespace JobsJobsJobs.Server.Data
return new Profile(CitizenId.Parse(rdr.GetString("id")), rdr.GetBoolean("seeking_employment"), return new Profile(CitizenId.Parse(rdr.GetString("id")), rdr.GetBoolean("seeking_employment"),
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"), rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")), rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
rdr.GetMilliseconds("last_updated_on"), rdr.GetInstant("last_updated_on"),
rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience"))) rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience")))
{ {
Continent = new Continent(continentId, rdr.GetString("continent_name")) Continent = new Continent(continentId, rdr.GetString("continent_name"))

View File

@ -7,8 +7,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
<PackageReference Include="Npgsql" Version="5.0.0" /> <PackageReference Include="Npgsql" 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,4 +1,3 @@
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;
@ -8,8 +7,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime;
using Npgsql; using Npgsql;
using System.Linq;
using System.Text; using System.Text;
namespace JobsJobsJobs.Server namespace JobsJobsJobs.Server
@ -28,6 +27,7 @@ namespace JobsJobsJobs.Server
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb"))); services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb")));
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddLogging(); services.AddLogging();
services.AddControllersWithViews(); services.AddControllersWithViews();
services.AddRazorPages(); services.AddRazorPages();
@ -54,6 +54,8 @@ namespace JobsJobsJobs.Server
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{ {
NpgsqlConnection.GlobalTypeMapper.UseNodaTime();
if (env.IsDevelopment()) if (env.IsDevelopment())
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
@ -69,10 +71,6 @@ namespace JobsJobsJobs.Server
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles(); app.UseBlazorFrameworkFiles();
app.UseStaticFiles(); app.UseStaticFiles();
// TODO: middleware to extract user info from our custom JWT, and see if it will fill the standard stuff.
// Alternately, maybe we can even configure the default services and middleware so that it will work
// to give us the currently-logged-on user.
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View File

@ -1,4 +1,6 @@
namespace JobsJobsJobs.Shared using NodaTime;
namespace JobsJobsJobs.Shared
{ {
/// <summary> /// <summary>
/// A user of Jobs, Jobs, Jobs /// A user of Jobs, Jobs, Jobs
@ -8,6 +10,6 @@
string NaUser, string NaUser,
string DisplayName, string DisplayName,
string ProfileUrl, string ProfileUrl,
Milliseconds JoinedOn, Instant JoinedOn,
Milliseconds LastSeenOn); Instant LastSeenOn);
} }

View File

@ -1,18 +0,0 @@
using System;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// Milliseconds past the epoch (JavaScript's date storage format)
/// </summary>
public record Milliseconds(long Millis)
{
/// <summary>
/// Get the milliseconds value for now
/// </summary>
/// <returns>A new milliseconds from the time now</returns>
public static Milliseconds Now() =>
new Milliseconds(
(DateTime.UtcNow.Ticks - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks) / 10000L);
}
}

View File

@ -1,4 +1,6 @@
namespace JobsJobsJobs.Shared using NodaTime;
namespace JobsJobsJobs.Shared
{ {
/// <summary> /// <summary>
/// A job seeker profile /// A job seeker profile
@ -12,7 +14,7 @@
bool RemoteWork, bool RemoteWork,
bool FullTime, bool FullTime,
MarkdownString Biography, MarkdownString Biography,
Milliseconds LastUpdatedOn, Instant LastUpdatedOn,
MarkdownString? Experience) MarkdownString? Experience)
{ {
/// <summary> /// <summary>

View File

@ -1,4 +1,6 @@
namespace JobsJobsJobs.Shared using NodaTime;
namespace JobsJobsJobs.Shared
{ {
/// <summary> /// <summary>
/// A record of success finding employment /// A record of success finding employment
@ -6,7 +8,7 @@
public record Success( public record Success(
SuccessId Id, SuccessId Id,
CitizenId CitizenId, CitizenId CitizenId,
Milliseconds RecordedOn, Instant RecordedOn,
bool FromHere, bool FromHere,
MarkdownString? Story); MarkdownString? Story);
} }

View File

@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="0.22.0" /> <PackageReference Include="Markdig" Version="0.22.0" />
<PackageReference Include="Nanoid" Version="2.1.0" /> <PackageReference Include="Nanoid" Version="2.1.0" />
<PackageReference Include="NodaTime" Version="3.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,8 +6,8 @@ CREATE TABLE jjj.citizen (
na_user VARCHAR(50) NOT NULL, na_user VARCHAR(50) NOT NULL,
display_name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL,
profile_url VARCHAR(1024) NOT NULL, profile_url VARCHAR(1024) NOT NULL,
joined_on BIGINT NOT NULL, joined_on TIMESTAMP NOT NULL,
last_seen_on BIGINT NOT NULL, last_seen_on TIMESTAMP NOT NULL,
CONSTRAINT pk_citizen PRIMARY KEY (id), CONSTRAINT pk_citizen PRIMARY KEY (id),
CONSTRAINT uk_na_user UNIQUE (na_user) CONSTRAINT uk_na_user UNIQUE (na_user)
); );
@ -45,7 +45,7 @@ CREATE TABLE jjj.profile (
remote_work BOOLEAN NOT NULL, remote_work BOOLEAN NOT NULL,
full_time BOOLEAN NOT NULL, full_time BOOLEAN NOT NULL,
biography TEXT NOT NULL, biography TEXT NOT NULL,
last_updated_on BIGINT NOT NULL, last_updated_on TIMESTAMP NOT NULL,
experience TEXT, experience TEXT,
CONSTRAINT pk_profile PRIMARY KEY (citizen_id), CONSTRAINT pk_profile PRIMARY KEY (citizen_id),
CONSTRAINT fk_profile_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id), CONSTRAINT fk_profile_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id),
@ -100,7 +100,7 @@ COMMENT ON INDEX jjj.idx_skill_citizen IS 'FK index';
CREATE TABLE jjj.success ( CREATE TABLE jjj.success (
id VARCHAR(12) NOT NULL, id VARCHAR(12) NOT NULL,
citizen_id VARCHAR(12) NOT NULL, citizen_id VARCHAR(12) NOT NULL,
recorded_on BIGINT NOT NULL, recorded_on TIMESTAMP NOT NULL,
from_here BOOLEAN NOT NULL, from_here BOOLEAN NOT NULL,
story TEXT, story TEXT,
CONSTRAINT pk_success PRIMARY KEY (id), CONSTRAINT pk_success PRIMARY KEY (id),