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,34 +10,25 @@ | ||||
| } | ||||
| else | ||||
| { | ||||
|   if (Profile != null) | ||||
|   { | ||||
|   <ErrorList Errors=@ErrorMessages> | ||||
|     @if (Profile != null) | ||||
|     { | ||||
|       <p> | ||||
|         Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently | ||||
|         lists @Profile.Skills.Length skill@(Profile.Skills.Length != 1 ? "s" : ""). | ||||
|       </p> | ||||
|     } | ||||
|     else | ||||
|     { | ||||
|       <p>You do not have an employment profile established; click “Profile”* in the menu to get started!</p> | ||||
|     } | ||||
|     <p> | ||||
|       Your employment profile was last updated <FullDateTime TheDate="@Profile.LastUpdatedOn" />. Your profile currently | ||||
|       lists @SkillCount skill@(SkillCount != 1 ? "s" : ""). | ||||
|       There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment | ||||
|       profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation. | ||||
|       @if (ProfileCount > 0) | ||||
|       { | ||||
|         <text>Take a look around and see if you can help them find work!</text> | ||||
|       } | ||||
|     </p> | ||||
|   } | ||||
|   else | ||||
|   { | ||||
|     <p>You do not have an employment profile established; click “Profile”* in the menu to get started!</p> | ||||
|   } | ||||
|   <p> | ||||
|     There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment | ||||
|     profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation. | ||||
|     @if (ProfileCount > 0) | ||||
|     { | ||||
|       <text>Take a look around and see if you can help them find work!</text> | ||||
|     } | ||||
|   </p> | ||||
| } | ||||
| 
 | ||||
| @if (ErrorMessages.Count > 0) | ||||
| { | ||||
|   <p><strong>The following error(s) occurred:</strong></p> | ||||
|   <p> | ||||
|     @foreach (var msg in ErrorMessages) | ||||
|     { | ||||
|       @msg<br> | ||||
|     } | ||||
|   </p> | ||||
|   </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) => | ||||
|             http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt); | ||||
|         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 | ||||
|         /// </summary> | ||||
|         /// <param name="rdr">The data reader from which values should be obtained</param> | ||||
|         /// <returns>The populated profile</returns> | ||||
|         private static Profile ToProfile(NpgsqlDataReader rdr) | ||||
|         { | ||||
|             var continentId = ContinentId.Parse(rdr.GetString("continent_id")); | ||||
|             return new Profile(CitizenId.Parse(rdr.GetString("citizen_id")), rdr.GetBoolean("seeking_employment"), | ||||
|                 rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"), | ||||
|                 rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")), | ||||
|                 rdr.GetInstant("last_updated_on"), | ||||
|                 rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience"))) | ||||
|             { | ||||
|                 Continent = new Continent(continentId, rdr.GetString("continent_name")) | ||||
|             }; | ||||
|         } | ||||
| 
 | ||||
|         /// <summary> | ||||
|         /// Populate a skill object from the given data reader | ||||
|         /// </summary> | ||||
|         /// <param name="rdr">The data reader from which values should be obtained</param> | ||||
|         /// <returns>The populated skill</returns> | ||||
|         private static Skill ToSkill(NpgsqlDataReader rdr) => | ||||
|             new Skill(SkillId.Parse(rdr.GetString("id")), CitizenId.Parse(rdr.GetString("citizen_id")), | ||||
|                 rdr.GetString("skill"), rdr.IsDBNull("notes") ? null : rdr.GetString("notes")); | ||||
| 
 | ||||
|         /// <summary> | ||||
|         /// Retrieve an employment profile by a citizen ID | ||||
|         /// </summary> | ||||
|         /// <param name="citizen">The ID of the citizen whose profile should be retrieved</param> | ||||
|         /// <param name="citizenId">The ID of the citizen whose profile should be retrieved</param> | ||||
|         /// <returns>The profile, or null if it does not exist</returns> | ||||
|         public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen) | ||||
|         public static async Task<Profile?> FindProfileByCitizen(this JobsDbContext db, CitizenId citizenId) | ||||
|         { | ||||
|             using var cmd = conn.CreateCommand(); | ||||
|             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); | ||||
|             var profile = await db.Profiles.AsNoTracking() | ||||
|                 .SingleOrDefaultAsync(p => p.Id == citizenId) | ||||
|                 .ConfigureAwait(false); | ||||
| 
 | ||||
|             using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); | ||||
|             return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null; | ||||
|             if (profile != null) | ||||
|             { | ||||
|                 return profile with | ||||
|                 { | ||||
|                     Continent = await db.FindContinentById(profile.ContinentId).ConfigureAwait(false), | ||||
|                     Skills = (await db.FindSkillsByCitizen(citizenId).ConfigureAwait(false)).ToArray() | ||||
|                 }; | ||||
|             } | ||||
| 
 | ||||
|             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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user