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
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Shared", "JobsJobsJobs\Shared\JobsJobsJobs.Shared.csproj", "{AE329284-47DA-4E76-B542-47489B271130}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU

View File

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

View File

@ -2,13 +2,20 @@
@inject HttpClient http
@inject AppState state
<h3>Dashboard</h3>
<h3>Welcome, @state.User!.Name!</h3>
<p>Here's the dashboard, homie...</p>
@if (hasProfile != null)
@if (retrievingProfile)
{
<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)
@ -17,21 +24,22 @@
}
@code {
bool? hasProfile = null;
bool retrievingProfile = true;
Profile? profile = null;
string? errorMessage = null;
protected override async Task OnInitializedAsync()
{
if (state.User != null)
{
var profile = await ServerApi.RetrieveProfile(http, state);
if (profile.IsOk)
var profileResult = await ServerApi.RetrieveProfile(http, state);
if (profileResult.IsOk)
{
hasProfile = profile.Ok != null;
profile = profileResult.Ok;
}
else
{
errorMessage = profile.Error;
errorMessage = profileResult.Error;
}
}
}

View File

@ -26,26 +26,32 @@
else
{
<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
</NavLink>
</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">
<NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Log Off
</NavLink>
</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>
</div>
@code {
protected override void OnInitialized()
{
base.OnInitialized();
state.OnChange += StateHasChanged;
}
/// <summary>
/// The client ID for Jobs, Jobs, Jobs at No Agenda Social
/// </summary>
@ -76,4 +82,4 @@
{
collapseNavMenu = !collapseNavMenu;
}
}
}

View File

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

View File

@ -20,7 +20,7 @@ namespace JobsJobsJobs.Server.Data
/// <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.GetMilliseconds("joined_on"), rdr.GetMilliseconds("last_seen_on"));
rdr.GetString("profile_url"), rdr.GetInstant("joined_on"), rdr.GetInstant("last_seen_on"));
/// <summary>
/// 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("@display_name", citizen.DisplayName));
cmd.Parameters.Add(new NpgsqlParameter("@profile_url", citizen.ProfileUrl));
cmd.Parameters.Add(new NpgsqlParameter("@joined_on", citizen.JoinedOn.Millis));
cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn.Millis));
cmd.Parameters.Add(new NpgsqlParameter("@joined_on", citizen.JoinedOn));
cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn));
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
@ -78,7 +78,7 @@ namespace JobsJobsJobs.Server.Data
WHERE id = @id";
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
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);
}

View File

@ -1,4 +1,5 @@
using JobsJobsJobs.Shared;
using NodaTime;
using Npgsql;
using System;
using System.Collections.Generic;
@ -19,6 +20,14 @@ namespace JobsJobsJobs.Server.Data
/// <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>
@ -26,14 +35,6 @@ namespace JobsJobsJobs.Server.Data
/// <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 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>
/// Get a string by its name
/// </summary>

View File

@ -23,7 +23,7 @@ namespace JobsJobsJobs.Server.Data
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("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")))
{
Continent = new Continent(continentId, rdr.GetString("continent_name"))

View File

@ -7,8 +7,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.0" />
<PackageReference Include="Npgsql" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
<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" />
</ItemGroup>

View File

@ -1,4 +1,3 @@
using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@ -8,8 +7,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using Npgsql;
using System.Linq;
using System.Text;
namespace JobsJobsJobs.Server
@ -28,6 +27,7 @@ namespace JobsJobsJobs.Server
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb")));
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddLogging();
services.AddControllersWithViews();
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.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
NpgsqlConnection.GlobalTypeMapper.UseNodaTime();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
@ -69,10 +71,6 @@ namespace JobsJobsJobs.Server
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
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.UseAuthentication();
app.UseAuthorization();

View File

@ -1,4 +1,6 @@
namespace JobsJobsJobs.Shared
using NodaTime;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A user of Jobs, Jobs, Jobs
@ -8,6 +10,6 @@
string NaUser,
string DisplayName,
string ProfileUrl,
Milliseconds JoinedOn,
Milliseconds LastSeenOn);
Instant JoinedOn,
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>
/// A job seeker profile
@ -12,7 +14,7 @@
bool RemoteWork,
bool FullTime,
MarkdownString Biography,
Milliseconds LastUpdatedOn,
Instant LastUpdatedOn,
MarkdownString? Experience)
{
/// <summary>

View File

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

View File

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

View File

@ -6,8 +6,8 @@ CREATE TABLE jjj.citizen (
na_user VARCHAR(50) NOT NULL,
display_name VARCHAR(255) NOT NULL,
profile_url VARCHAR(1024) NOT NULL,
joined_on BIGINT NOT NULL,
last_seen_on BIGINT NOT NULL,
joined_on TIMESTAMP NOT NULL,
last_seen_on TIMESTAMP NOT NULL,
CONSTRAINT pk_citizen PRIMARY KEY (id),
CONSTRAINT uk_na_user UNIQUE (na_user)
);
@ -45,7 +45,7 @@ CREATE TABLE jjj.profile (
remote_work BOOLEAN NOT NULL,
full_time BOOLEAN NOT NULL,
biography TEXT NOT NULL,
last_updated_on BIGINT NOT NULL,
last_updated_on TIMESTAMP NOT NULL,
experience TEXT,
CONSTRAINT pk_profile PRIMARY KEY (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 (
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,
story TEXT,
CONSTRAINT pk_success PRIMARY KEY (id),