Migrate auth/logon handler
This commit is contained in:
parent
28b116925b
commit
77f402d9dd
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Data.fs" />
|
<Compile Include="Data.fs" />
|
||||||
|
<Compile Include="Auth.fs" />
|
||||||
|
<Compile Include="Handlers.fs" />
|
||||||
<Compile Include="App.fs" />
|
<Compile Include="App.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -20,6 +22,7 @@
|
||||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||||
<PackageReference Include="Polly" Version="7.2.2" />
|
<PackageReference Include="Polly" Version="7.2.2" />
|
||||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,26 +1,30 @@
|
||||||
/// The main API application for Jobs, Jobs, Jobs
|
/// The main API application for Jobs, Jobs, Jobs
|
||||||
module JobsJobsJobs.Api.App
|
module JobsJobsJobs.Api.App
|
||||||
|
|
||||||
//open System
|
|
||||||
open Microsoft.AspNetCore.Builder
|
open Microsoft.AspNetCore.Builder
|
||||||
open Microsoft.AspNetCore.Hosting
|
open Microsoft.AspNetCore.Hosting
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open Microsoft.Extensions.Hosting
|
open Microsoft.Extensions.Hosting
|
||||||
open Giraffe
|
open Giraffe
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
/// All available routes for the application
|
/// All available routes for the application
|
||||||
let webApp =
|
let webApp = [
|
||||||
choose [
|
subRoute "/api" [
|
||||||
route "/ping" >=> text "pong"
|
subRoute "/citizen" [
|
||||||
route "/" >=> htmlFile "/pages/index.html"
|
GET [ routef "/log-on/%s" Handlers.Citizen.logOn ]
|
||||||
|
]
|
||||||
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Configure the ASP.NET Core pipeline to use Giraffe
|
/// Configure the ASP.NET Core pipeline to use Giraffe
|
||||||
let configureApp (app : IApplicationBuilder) =
|
let configureApp (app : IApplicationBuilder) =
|
||||||
app.UseGiraffe webApp
|
app
|
||||||
|
.UseRouting()
|
||||||
|
.UseEndpoints(fun e -> e.MapGiraffeEndpoints webApp)
|
||||||
|
|> ignore
|
||||||
|
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open Microsoft.Extensions.Configuration
|
open Microsoft.Extensions.Configuration
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
|
@ -31,7 +35,7 @@ let configureServices (svc : IServiceCollection) =
|
||||||
svc.AddLogging () |> ignore
|
svc.AddLogging () |> ignore
|
||||||
let svcs = svc.BuildServiceProvider()
|
let svcs = svc.BuildServiceProvider()
|
||||||
let cfg = svcs.GetRequiredService<IConfiguration>().GetSection "Rethink"
|
let cfg = svcs.GetRequiredService<IConfiguration>().GetSection "Rethink"
|
||||||
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger "Data.Startup"
|
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
||||||
let conn = Data.Startup.createConnection cfg log
|
let conn = Data.Startup.createConnection cfg log
|
||||||
svc.AddSingleton conn |> ignore
|
svc.AddSingleton conn |> ignore
|
||||||
Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore
|
Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore
|
||||||
|
|
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
/// Authorization / authentication functions
|
||||||
|
module JobsJobsJobs.Api.Auth
|
||||||
|
|
||||||
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
|
/// The variables we need from the account information we get from No Agenda Social
|
||||||
|
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||||
|
type MastodonAccount () =
|
||||||
|
/// The user name (what we store as naUser)
|
||||||
|
[<JsonPropertyName "username">]
|
||||||
|
member val Username = "" with get, set
|
||||||
|
/// The account name; will be the same as username for local (non-federated) accounts
|
||||||
|
[<JsonPropertyName "acct">]
|
||||||
|
member val AccountName = "" with get, set
|
||||||
|
/// The user's display name as it currently shows on No Agenda Social
|
||||||
|
[<JsonPropertyName "display_name">]
|
||||||
|
member val DisplayName = "" with get, set
|
||||||
|
/// The user's profile URL
|
||||||
|
[<JsonPropertyName "url">]
|
||||||
|
member val Url = "" with get, set
|
||||||
|
|
||||||
|
|
||||||
|
open FSharp.Control.Tasks
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open System
|
||||||
|
open System.Net.Http
|
||||||
|
open System.Net.Http.Headers
|
||||||
|
open System.Net.Http.Json
|
||||||
|
open System.Text.Json
|
||||||
|
|
||||||
|
/// Verify the authorization code with Mastodon and get the user's profile
|
||||||
|
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
|
||||||
|
|
||||||
|
use http = new HttpClient()
|
||||||
|
|
||||||
|
// Use authorization code to get an access token from NAS
|
||||||
|
use! codeResult =
|
||||||
|
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token",
|
||||||
|
{| client_id = cfg.["ClientId"]
|
||||||
|
client_secret = cfg.["Secret"]
|
||||||
|
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"]
|
||||||
|
grant_type = "authorization_code"
|
||||||
|
code = authCode
|
||||||
|
scope = "read"
|
||||||
|
|})
|
||||||
|
match codeResult.IsSuccessStatusCode with
|
||||||
|
| true ->
|
||||||
|
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
||||||
|
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
||||||
|
match tokenResponse with
|
||||||
|
| null ->
|
||||||
|
return Error "Could not parse authorization code result"
|
||||||
|
| _ ->
|
||||||
|
// Use access token to get profile from NAS
|
||||||
|
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"])
|
||||||
|
req.Headers.Authorization <- AuthenticationHeaderValue
|
||||||
|
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
||||||
|
use! profileResult = http.SendAsync req
|
||||||
|
|
||||||
|
match profileResult.IsSuccessStatusCode with
|
||||||
|
| true ->
|
||||||
|
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
||||||
|
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
||||||
|
| null ->
|
||||||
|
return Error "Could not parse profile result"
|
||||||
|
| x when x.Username <> x.AccountName ->
|
||||||
|
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
|
||||||
|
| profile ->
|
||||||
|
return Ok profile
|
||||||
|
| false ->
|
||||||
|
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
||||||
|
| false ->
|
||||||
|
let! err = codeResult.Content.ReadAsStringAsync ()
|
||||||
|
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
||||||
|
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.IdentityModel.Tokens
|
||||||
|
open System.IdentityModel.Tokens.Jwt
|
||||||
|
open System.Security.Claims
|
||||||
|
open System.Text
|
||||||
|
|
||||||
|
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
||||||
|
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
||||||
|
|
||||||
|
let tokenHandler = JwtSecurityTokenHandler ()
|
||||||
|
let token =
|
||||||
|
tokenHandler.CreateToken (
|
||||||
|
SecurityTokenDescriptor (
|
||||||
|
Subject = ClaimsIdentity [|
|
||||||
|
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
|
||||||
|
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||||
|
|],
|
||||||
|
Expires = DateTime.UtcNow.AddHours 2.,
|
||||||
|
Issuer = "https://noagendacareers.com",
|
||||||
|
Audience = "https://noagendacareers.com",
|
||||||
|
SigningCredentials = SigningCredentials (
|
||||||
|
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
|
||||||
|
SecurityAlgorithms.HmacSha256Signature)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tokenHandler.WriteToken token
|
||||||
|
|
|
@ -220,6 +220,17 @@ module Citizen =
|
||||||
()
|
()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the display name and last seen on date for a citizen
|
||||||
|
let logOnUpdate (citizen : Citizen) conn = task {
|
||||||
|
let! _ =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () ->
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.Get(citizen.id)
|
||||||
|
.Update(r.HashMap(nameof citizen.displayName, citizen.displayName)
|
||||||
|
.With(nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
||||||
|
.RunWriteAsync conn)
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
/// Profile data access functions
|
/// Profile data access functions
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
|
|
101
src/JobsJobsJobs/Api/Handlers.fs
Normal file
101
src/JobsJobsJobs/Api/Handlers.fs
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
/// Route handlers for Giraffe endpoints
|
||||||
|
module JobsJobsJobs.Api.Handlers
|
||||||
|
|
||||||
|
open FSharp.Control.Tasks
|
||||||
|
open Giraffe
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
|
||||||
|
/// Helper functions
|
||||||
|
[<AutoOpen>]
|
||||||
|
module Helpers =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open RethinkDb.Driver.Net
|
||||||
|
|
||||||
|
/// Get the NodaTime clock from the request context
|
||||||
|
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
||||||
|
|
||||||
|
/// Get the application configuration from the request context
|
||||||
|
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||||
|
|
||||||
|
/// Get the logger factory from the request context
|
||||||
|
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||||
|
|
||||||
|
/// Get the RethinkDB connection from the request context
|
||||||
|
let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
|
||||||
|
|
||||||
|
/// Return None if the string is null, empty, or whitespace; otherwise, return Some and the trimmed string
|
||||||
|
let noneIfEmpty x =
|
||||||
|
match (defaultArg (Option.ofObj x) "").Trim () with | "" -> None | it -> Some it
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for error conditions
|
||||||
|
module Error =
|
||||||
|
|
||||||
|
/// Handler that will return a status code 404 and the text "Not Found"
|
||||||
|
let notFound : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next ctx
|
||||||
|
|
||||||
|
|
||||||
|
/// Handler to return the files required for the Vue client app
|
||||||
|
module Vue =
|
||||||
|
|
||||||
|
/// Handler that returns index.html (the Vue client app)
|
||||||
|
let app : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||||
|
| true -> htmlFile "wwwroot/index.html" next ctx
|
||||||
|
| false -> Error.notFound next ctx
|
||||||
|
|
||||||
|
|
||||||
|
/// Handler for /api/citizen routes
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
// GET: /api/citizen/log-on/[code]
|
||||||
|
let logOn authCode : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
// Step 1 - Verify with Mastodon
|
||||||
|
let cfg = (config ctx).GetSection "Auth"
|
||||||
|
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
||||||
|
|
||||||
|
match! Auth.verifyWithMastodon authCode cfg log with
|
||||||
|
| Ok account ->
|
||||||
|
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let! citizen = task {
|
||||||
|
match! Data.Citizen.findByNaUser account.Username dbConn with
|
||||||
|
| None ->
|
||||||
|
let it : Citizen =
|
||||||
|
{ id = CitizenId.create ()
|
||||||
|
naUser = account.Username
|
||||||
|
displayName = noneIfEmpty account.DisplayName
|
||||||
|
realName = None
|
||||||
|
profileUrl = account.Url
|
||||||
|
joinedOn = now
|
||||||
|
lastSeenOn = now
|
||||||
|
}
|
||||||
|
do! Data.Citizen.add it dbConn
|
||||||
|
return it
|
||||||
|
| Some citizen ->
|
||||||
|
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
||||||
|
do! Data.Citizen.logOnUpdate it dbConn
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 - Generate JWT
|
||||||
|
return!
|
||||||
|
json
|
||||||
|
{ jwt = Auth.createJwt citizen cfg
|
||||||
|
citizenId = CitizenId.toString citizen.id
|
||||||
|
name = Citizen.name citizen
|
||||||
|
} next ctx
|
||||||
|
| Error err ->
|
||||||
|
return! RequestErrors.BAD_REQUEST err next ctx
|
||||||
|
}
|
|
@ -39,11 +39,15 @@ let main argv =
|
||||||
let host = createHostBuilder argv
|
let host = createHostBuilder argv
|
||||||
let r = RethinkDb.Driver.RethinkDB.R
|
let r = RethinkDb.Driver.RethinkDB.R
|
||||||
|
|
||||||
|
printfn "0) Connecting to databases..."
|
||||||
|
|
||||||
use db = host.Services.GetRequiredService<JobsDbContext> ()
|
use db = host.Services.GetRequiredService<JobsDbContext> ()
|
||||||
let conn = host.Services.GetRequiredService<IConnection> ()
|
let conn = host.Services.GetRequiredService<IConnection> ()
|
||||||
|
|
||||||
task {
|
task {
|
||||||
// Migrate continents
|
|
||||||
|
printfn "1) Migrating continents..."
|
||||||
|
|
||||||
let mutable continentXref = Map.empty<string, Types.ContinentId>
|
let mutable continentXref = Map.empty<string, Types.ContinentId>
|
||||||
let! continents = db.Continents.AsNoTracking().ToListAsync ()
|
let! continents = db.Continents.AsNoTracking().ToListAsync ()
|
||||||
let reContinents =
|
let reContinents =
|
||||||
|
@ -59,7 +63,8 @@ let main argv =
|
||||||
|> List.ofSeq
|
|> List.ofSeq
|
||||||
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
|
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
|
||||||
|
|
||||||
// Migrate citizens
|
printfn "2) Migrating citizens..."
|
||||||
|
|
||||||
let mutable citizenXref = Map.empty<string, Types.CitizenId>
|
let mutable citizenXref = Map.empty<string, Types.CitizenId>
|
||||||
let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
|
let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
|
||||||
let reCitizens =
|
let reCitizens =
|
||||||
|
@ -79,7 +84,8 @@ let main argv =
|
||||||
it)
|
it)
|
||||||
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn
|
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn
|
||||||
|
|
||||||
// Migrate profile information (includes skills)
|
printfn "3) Migrating profiles and skills..."
|
||||||
|
|
||||||
let! profiles = db.Profiles.AsNoTracking().ToListAsync ()
|
let! profiles = db.Profiles.AsNoTracking().ToListAsync ()
|
||||||
let reProfiles =
|
let reProfiles =
|
||||||
profiles
|
profiles
|
||||||
|
@ -103,15 +109,16 @@ let main argv =
|
||||||
region = p.Region
|
region = p.Region
|
||||||
remoteWork = p.RemoteWork
|
remoteWork = p.RemoteWork
|
||||||
fullTime = p.FullTime
|
fullTime = p.FullTime
|
||||||
biography = Types.Text (string p.Biography)
|
biography = Types.Text p.Biography.Text
|
||||||
lastUpdatedOn = p.LastUpdatedOn
|
lastUpdatedOn = p.LastUpdatedOn
|
||||||
experience = match p.Experience with null -> None | x -> (string >> Types.Text >> Some) x
|
experience = match p.Experience with null -> None | x -> (Types.Text >> Some) x.Text
|
||||||
skills = reSkills
|
skills = reSkills
|
||||||
}
|
}
|
||||||
it)
|
it)
|
||||||
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
|
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
|
||||||
|
|
||||||
// Migrate success stories
|
printfn "4) Migrating success stories..."
|
||||||
|
|
||||||
let! successes = db.Successes.AsNoTracking().ToListAsync ()
|
let! successes = db.Successes.AsNoTracking().ToListAsync ()
|
||||||
let reSuccesses =
|
let reSuccesses =
|
||||||
successes
|
successes
|
||||||
|
@ -122,7 +129,7 @@ let main argv =
|
||||||
recordedOn = s.RecordedOn
|
recordedOn = s.RecordedOn
|
||||||
fromHere = s.FromHere
|
fromHere = s.FromHere
|
||||||
source = "profile"
|
source = "profile"
|
||||||
story = match s.Story with null -> None | x -> (string >> Types.Text >> Some) x
|
story = match s.Story with null -> None | x -> (Types.Text >> Some) x.Text
|
||||||
}
|
}
|
||||||
it)
|
it)
|
||||||
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
|
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
|
||||||
|
@ -130,4 +137,6 @@ let main argv =
|
||||||
}
|
}
|
||||||
|> awaitIgnore
|
|> awaitIgnore
|
||||||
|
|
||||||
|
printfn "Migration complete"
|
||||||
|
|
||||||
0
|
0
|
1
src/JobsJobsJobs/Domain/.gitignore
vendored
Normal file
1
src/JobsJobsJobs/Domain/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*.js
|
|
@ -9,6 +9,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Types.fs" />
|
<Compile Include="Types.fs" />
|
||||||
<Compile Include="Modules.fs" />
|
<Compile Include="Modules.fs" />
|
||||||
|
<Compile Include="SharedTypes.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
14
src/JobsJobsJobs/Domain/SharedTypes.fs
Normal file
14
src/JobsJobsJobs/Domain/SharedTypes.fs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/// Types intended to be shared between the API and the client application
|
||||||
|
module JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
|
// fsharplint:disable FieldNames
|
||||||
|
|
||||||
|
/// A successful logon
|
||||||
|
type LogOnSuccess = {
|
||||||
|
/// The JSON Web Token (JWT) to use for API access
|
||||||
|
jwt : string
|
||||||
|
/// The ID of the logged-in citizen (as a string)
|
||||||
|
citizenId : string
|
||||||
|
/// The name of the logged-in citizen
|
||||||
|
name : string
|
||||||
|
}
|
|
@ -53,7 +53,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||||
if (form.Id == "new")
|
if (form.Id == "new")
|
||||||
{
|
{
|
||||||
var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(),
|
var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(),
|
||||||
form.FromHere, "profile",
|
form.FromHere, // "profile",
|
||||||
string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story));
|
string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story));
|
||||||
await _db.AddAsync(story);
|
await _db.AddAsync(story);
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,7 +143,7 @@ namespace JobsJobsJobs.Server.Data
|
||||||
.HasConversion(Converters.CitizenIdConverter);
|
.HasConversion(Converters.CitizenIdConverter);
|
||||||
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
|
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
|
||||||
m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired();
|
m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired();
|
||||||
m.Property(e => e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
|
// m.Property(e => e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
|
||||||
m.Property(e => e.Story).HasColumnName("story")
|
m.Property(e => e.Story).HasColumnName("story")
|
||||||
.HasConversion(Converters.OptionalMarkdownStringConverter);
|
.HasConversion(Converters.OptionalMarkdownStringConverter);
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,6 +10,6 @@ namespace JobsJobsJobs.Shared
|
||||||
CitizenId CitizenId,
|
CitizenId CitizenId,
|
||||||
Instant RecordedOn,
|
Instant RecordedOn,
|
||||||
bool FromHere,
|
bool FromHere,
|
||||||
string Source,
|
// string Source,
|
||||||
MarkdownString? Story);
|
MarkdownString? Story);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user