diff --git a/src/JobsJobsJobs.sln b/src/JobsJobsJobs.sln index d3bbc63..77b6417 100644 --- a/src/JobsJobsJobs.sln +++ b/src/JobsJobsJobs.sln @@ -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 diff --git a/src/JobsJobsJobs/Client/AppState.cs b/src/JobsJobsJobs/Client/AppState.cs index 6a771a8..62abfa9 100644 --- a/src/JobsJobsJobs/Client/AppState.cs +++ b/src/JobsJobsJobs/Client/AppState.cs @@ -1,4 +1,5 @@ using JobsJobsJobs.Shared; +using System; namespace JobsJobsJobs.Client { @@ -12,16 +13,40 @@ namespace JobsJobsJobs.Client /// public class AppState { + public event Action OnChange = () => { }; + + private UserInfo? _user = null; + /// /// The information of the currently logged-in user /// - public UserInfo? User { get; set; } = null; + public UserInfo? User + { + get => _user; + set + { + _user = value; + NotifyChanged(); + } + } + + private string _jwt = ""; /// /// The JSON Web Token (JWT) for the currently logged-on user /// - public string Jwt { get; set; } = ""; + public string Jwt + { + get => _jwt; + set + { + _jwt = value; + NotifyChanged(); + } + } public AppState() { } + + private void NotifyChanged() => OnChange.Invoke(); } } diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor index 33a7a87..0b21952 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor @@ -2,13 +2,20 @@ @inject HttpClient http @inject AppState state -

Dashboard

+

Welcome, @state.User!.Name!

-

Here's the dashboard, homie...

- -@if (hasProfile != null) +@if (retrievingProfile) { -

Has profile? @hasProfile

+

Retrieving your employment profile...

+} +else if (profile != null) +{ +

Your employment profile was last updated @profile.LastUpdatedOn

+} +else +{ +

You do not have an employment profile established; click “Profile”* in the menu to get started!

+

* Once it's there...

} @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; } } } diff --git a/src/JobsJobsJobs/Client/Shared/NavMenu.razor b/src/JobsJobsJobs/Client/Shared/NavMenu.razor index 1e4fd93..d0fc6c4 100644 --- a/src/JobsJobsJobs/Client/Shared/NavMenu.razor +++ b/src/JobsJobsJobs/Client/Shared/NavMenu.razor @@ -26,26 +26,32 @@ else { + } - @code { + protected override void OnInitialized() + { + base.OnInitialized(); + state.OnChange += StateHasChanged; + } + /// /// The client ID for Jobs, Jobs, Jobs at No Agenda Social /// @@ -76,4 +82,4 @@ { collapseNavMenu = !collapseNavMenu; } -} +} \ No newline at end of file diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/CitizenController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/CitizenController.cs index 6d53ac6..b79380e 100644 --- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/CitizenController.cs +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/CitizenController.cs @@ -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); } diff --git a/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs b/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs index b2e4cd2..9a58071 100644 --- a/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs @@ -20,7 +20,7 @@ namespace JobsJobsJobs.Server.Data /// A populated citizen 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")); /// /// 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); } diff --git a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs b/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs index 4d3f588..feaf76d 100644 --- a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs @@ -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 /// The specified field as a boolean public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name)); + /// + /// Get an Instant by its name + /// + /// The name of the field to be retrieved as an Instant + /// The specified field as an Instant + public static Instant GetInstant(this NpgsqlDataReader rdr, string name) => + rdr.GetFieldValue(rdr.GetOrdinal(name)); + /// /// Get a 64-bit integer by its name /// @@ -26,14 +35,6 @@ namespace JobsJobsJobs.Server.Data /// The specified field as a 64-bit integer public static long GetInt64(this NpgsqlDataReader rdr, string name) => rdr.GetInt64(rdr.GetOrdinal(name)); - /// - /// Get milliseconds by its name - /// - /// The name of the field to be retrieved as milliseconds - /// The specified field as milliseconds - public static Milliseconds GetMilliseconds(this NpgsqlDataReader rdr, string name) => - new Milliseconds(rdr.GetInt64(name)); - /// /// Get a string by its name /// diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs index b27d777..363ba32 100644 --- a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs @@ -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")) diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj index e18f2c4..dd6057c 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj @@ -7,8 +7,9 @@ - - + + + diff --git a/src/JobsJobsJobs/Server/Startup.cs b/src/JobsJobsJobs/Server/Startup.cs index af3d54a..5c8018d 100644 --- a/src/JobsJobsJobs/Server/Startup.cs +++ b/src/JobsJobsJobs/Server/Startup.cs @@ -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(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(); diff --git a/src/JobsJobsJobs/Shared/Domain/Citizen.cs b/src/JobsJobsJobs/Shared/Domain/Citizen.cs index 65b205d..f2ff382 100644 --- a/src/JobsJobsJobs/Shared/Domain/Citizen.cs +++ b/src/JobsJobsJobs/Shared/Domain/Citizen.cs @@ -1,4 +1,6 @@ -namespace JobsJobsJobs.Shared +using NodaTime; + +namespace JobsJobsJobs.Shared { /// /// A user of Jobs, Jobs, Jobs @@ -8,6 +10,6 @@ string NaUser, string DisplayName, string ProfileUrl, - Milliseconds JoinedOn, - Milliseconds LastSeenOn); + Instant JoinedOn, + Instant LastSeenOn); } diff --git a/src/JobsJobsJobs/Shared/Domain/Milliseconds.cs b/src/JobsJobsJobs/Shared/Domain/Milliseconds.cs deleted file mode 100644 index ac86b99..0000000 --- a/src/JobsJobsJobs/Shared/Domain/Milliseconds.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace JobsJobsJobs.Shared -{ - /// - /// Milliseconds past the epoch (JavaScript's date storage format) - /// - public record Milliseconds(long Millis) - { - /// - /// Get the milliseconds value for now - /// - /// A new milliseconds from the time now - public static Milliseconds Now() => - new Milliseconds( - (DateTime.UtcNow.Ticks - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks) / 10000L); - } -} diff --git a/src/JobsJobsJobs/Shared/Domain/Profile.cs b/src/JobsJobsJobs/Shared/Domain/Profile.cs index 7006af5..604cf9e 100644 --- a/src/JobsJobsJobs/Shared/Domain/Profile.cs +++ b/src/JobsJobsJobs/Shared/Domain/Profile.cs @@ -1,4 +1,6 @@ -namespace JobsJobsJobs.Shared +using NodaTime; + +namespace JobsJobsJobs.Shared { /// /// A job seeker profile @@ -12,7 +14,7 @@ bool RemoteWork, bool FullTime, MarkdownString Biography, - Milliseconds LastUpdatedOn, + Instant LastUpdatedOn, MarkdownString? Experience) { /// diff --git a/src/JobsJobsJobs/Shared/Domain/Success.cs b/src/JobsJobsJobs/Shared/Domain/Success.cs index 2c2958b..502d379 100644 --- a/src/JobsJobsJobs/Shared/Domain/Success.cs +++ b/src/JobsJobsJobs/Shared/Domain/Success.cs @@ -1,4 +1,6 @@ -namespace JobsJobsJobs.Shared +using NodaTime; + +namespace JobsJobsJobs.Shared { /// /// 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); } diff --git a/src/JobsJobsJobs/Shared/JobsJobsJobs.Shared.csproj b/src/JobsJobsJobs/Shared/JobsJobsJobs.Shared.csproj index bd50b80..84ed6e0 100644 --- a/src/JobsJobsJobs/Shared/JobsJobsJobs.Shared.csproj +++ b/src/JobsJobsJobs/Shared/JobsJobsJobs.Shared.csproj @@ -8,6 +8,7 @@ + diff --git a/src/database/tables.sql b/src/database/tables.sql index 0231cc6..c660257 100644 --- a/src/database/tables.sql +++ b/src/database/tables.sql @@ -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),