Merge pull request #1 from danieljsummers/node-api-attempt
Node API attempt was successful
This commit is contained in:
commit
f5a14fb66e
.gitignore
src
api
App.fsData.fsDependencies.fsEntities.fsExtensions.fs
Migrations
MyPrayerJournal.fsprojapp.jsdb
index.jsjson.mjspackage.jsonroutes
yarn.lockapp
config
src
api
auth
components
store
4
.gitignore
vendored
4
.gitignore
vendored
@ -253,7 +253,7 @@ paket-files/
|
|||||||
*.sln.iml
|
*.sln.iml
|
||||||
|
|
||||||
# Compiled files / application
|
# Compiled files / application
|
||||||
src/api/wwwroot/index.html
|
src/api/public/index.html
|
||||||
src/api/wwwroot/static
|
src/api/public/static
|
||||||
src/api/appsettings.json
|
src/api/appsettings.json
|
||||||
build/
|
build/
|
195
src/api/App.fs
195
src/api/App.fs
@ -1,195 +0,0 @@
|
|||||||
/// Main server module for myPrayerJournal
|
|
||||||
module MyPrayerJournal.App
|
|
||||||
|
|
||||||
open Microsoft.EntityFrameworkCore
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open Newtonsoft.Json.Linq
|
|
||||||
open System
|
|
||||||
open System.IO
|
|
||||||
open Suave
|
|
||||||
open Suave.Filters
|
|
||||||
open Suave.Operators
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
|
|
||||||
/// Auth0 settings
|
|
||||||
type Auth0Config = {
|
|
||||||
/// The domain used with Auth0
|
|
||||||
Domain : string
|
|
||||||
/// The client Id
|
|
||||||
ClientId : string
|
|
||||||
/// The base64-encoded client secret
|
|
||||||
ClientSecret : string
|
|
||||||
/// The URL-safe base64-encoded client secret
|
|
||||||
ClientSecretJwt : string
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// An empty set of Auth0 settings
|
|
||||||
static member empty =
|
|
||||||
{ Domain = ""
|
|
||||||
ClientId = ""
|
|
||||||
ClientSecret = ""
|
|
||||||
ClientSecretJwt = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Application configuration
|
|
||||||
type AppConfig = {
|
|
||||||
/// PostgreSQL connection string
|
|
||||||
Conn : string
|
|
||||||
/// Auth0 settings
|
|
||||||
Auth0 : Auth0Config
|
|
||||||
}
|
|
||||||
with
|
|
||||||
static member empty =
|
|
||||||
{ Conn = ""
|
|
||||||
Auth0 = Auth0Config.empty
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A JSON response as a data property
|
|
||||||
type JsonOkResponse<'a> = {
|
|
||||||
data : 'a
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A JSON response indicating an error occurred
|
|
||||||
type JsonErrorResponse = {
|
|
||||||
error : string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Configuration instances
|
|
||||||
module Config =
|
|
||||||
|
|
||||||
/// Application configuration
|
|
||||||
let app =
|
|
||||||
try
|
|
||||||
use sr = File.OpenText "appsettings.json"
|
|
||||||
use tr = new JsonTextReader (sr)
|
|
||||||
let settings = JToken.ReadFrom tr
|
|
||||||
let secret = settings.["auth0"].["client-secret"].ToObject<string>()
|
|
||||||
{ Conn = settings.["conn"].ToObject<string>()
|
|
||||||
Auth0 =
|
|
||||||
{ Domain = settings.["auth0"].["domain"].ToObject<string>()
|
|
||||||
ClientId = settings.["auth0"].["client-id"].ToObject<string>()
|
|
||||||
ClientSecret = secret
|
|
||||||
ClientSecretJwt = secret.TrimEnd('=').Replace("-", "+").Replace("_", "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with _ -> AppConfig.empty
|
|
||||||
|
|
||||||
/// Custom Suave configuration
|
|
||||||
let suave =
|
|
||||||
{ defaultConfig with
|
|
||||||
homeFolder = Some (Path.GetFullPath "./wwwroot/")
|
|
||||||
serverKey = Text.Encoding.UTF8.GetBytes("12345678901234567890123456789012")
|
|
||||||
bindings = [ HttpBinding.createSimple HTTP "127.0.0.1" 8084 ]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Authorization functions
|
|
||||||
module Auth =
|
|
||||||
|
|
||||||
/// Shorthand for Console.WriteLine
|
|
||||||
let cw (x : string) = Console.WriteLine x
|
|
||||||
|
|
||||||
/// Convert microtime to ticks, add difference from 1/1/1 to 1/1/1970
|
|
||||||
let jsDate jsTicks =
|
|
||||||
DateTime(jsTicks * 10000000L).AddTicks(DateTime(1970, 1, 1).Ticks)
|
|
||||||
|
|
||||||
/// Get the user Id (sub) from a JSON Web Token
|
|
||||||
let getIdFromToken jwt =
|
|
||||||
try
|
|
||||||
let payload = Jose.JWT.Decode<JObject>(jwt, Config.app.Auth0.ClientSecretJwt)
|
|
||||||
let tokenExpires = jsDate (payload.["exp"].ToObject<int64>())
|
|
||||||
match tokenExpires > DateTime.UtcNow with
|
|
||||||
| true -> Some (payload.["sub"].ToObject<string>())
|
|
||||||
| _ -> None
|
|
||||||
with ex ->
|
|
||||||
sprintf "Token Deserialization Exception - %s" (ex.GetType().FullName) |> cw
|
|
||||||
sprintf "Message - %s" ex.Message |> cw
|
|
||||||
ex.StackTrace |> cw
|
|
||||||
None
|
|
||||||
|
|
||||||
/// Add the logged on user Id to the context if it exists
|
|
||||||
let loggedOn =
|
|
||||||
warbler (fun ctx ->
|
|
||||||
match ctx.request.header "Authorization" with
|
|
||||||
| Choice1Of2 bearer -> Writers.setUserData "user" (getIdFromToken <| bearer.Split(' ').[1])
|
|
||||||
| _ -> Writers.setUserData "user" None)
|
|
||||||
|
|
||||||
|
|
||||||
// --- Support ---
|
|
||||||
|
|
||||||
/// Get the scheme, host, and port of the URL
|
|
||||||
let schemeHostPort (req : HttpRequest) =
|
|
||||||
sprintf "%s://%s" req.url.Scheme (req.headers |> List.filter (fun x -> fst x = "host") |> List.head |> snd)
|
|
||||||
|
|
||||||
/// Serialize an object to JSON
|
|
||||||
let toJson = JsonConvert.SerializeObject
|
|
||||||
|
|
||||||
/// Read an item from the user state, downcast to the expected type
|
|
||||||
let read ctx key : 'value =
|
|
||||||
ctx.userState |> Map.tryFind key |> Option.map (fun x -> x :?> 'value) |> Option.get
|
|
||||||
|
|
||||||
/// Create a new data context
|
|
||||||
let dataCtx () =
|
|
||||||
new DataContext (((DbContextOptionsBuilder<DataContext>()).UseNpgsql Config.app.Conn).Options)
|
|
||||||
|
|
||||||
/// Ensure the EF context is created in the right format
|
|
||||||
let ensureDatabase () =
|
|
||||||
async {
|
|
||||||
use data = dataCtx ()
|
|
||||||
do! data.Database.MigrateAsync ()
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
|
|
||||||
/// URL routes for myPrayerJournal
|
|
||||||
module Route =
|
|
||||||
|
|
||||||
/// /api/journal ~ All active prayer requests for a user
|
|
||||||
let journal = "/api/journal"
|
|
||||||
|
|
||||||
/// All WebParts that compose the public API
|
|
||||||
module WebParts =
|
|
||||||
|
|
||||||
let jsonMimeType =
|
|
||||||
warbler (fun ctx -> Writers.setMimeType "application/json; charset=utf8")
|
|
||||||
|
|
||||||
/// WebPart to return a JSON response
|
|
||||||
let JSON payload =
|
|
||||||
jsonMimeType
|
|
||||||
>=> Successful.OK (toJson { data = payload })
|
|
||||||
|
|
||||||
/// WebPart to return an JSON error response
|
|
||||||
let errorJSON code error =
|
|
||||||
jsonMimeType
|
|
||||||
>=> Response.response code ((toJson >> Text.Encoding.UTF8.GetBytes) { error = error })
|
|
||||||
|
|
||||||
/// Journal page
|
|
||||||
let viewJournal =
|
|
||||||
context (fun ctx ->
|
|
||||||
use dataCtx = dataCtx ()
|
|
||||||
let reqs = Data.Requests.allForUser (defaultArg (read ctx "user") "") dataCtx
|
|
||||||
JSON reqs)
|
|
||||||
|
|
||||||
/// API-specific routes
|
|
||||||
let apiRoutes =
|
|
||||||
choose [
|
|
||||||
GET >=> path Route.journal >=> viewJournal
|
|
||||||
errorJSON HttpCode.HTTP_404 "Page not found"
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Suave application
|
|
||||||
let app =
|
|
||||||
Auth.loggedOn
|
|
||||||
>=> choose [
|
|
||||||
path "/api" >=> apiRoutes
|
|
||||||
Files.browseHome
|
|
||||||
Files.browseFileHome "index.html"
|
|
||||||
]
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
|
||||||
let main argv =
|
|
||||||
ensureDatabase ()
|
|
||||||
startWebServer Config.suave WebParts.app
|
|
||||||
0
|
|
@ -1,57 +0,0 @@
|
|||||||
namespace MyPrayerJournal
|
|
||||||
|
|
||||||
open Microsoft.EntityFrameworkCore
|
|
||||||
open System.Linq
|
|
||||||
open System.Runtime.CompilerServices
|
|
||||||
|
|
||||||
/// Data context for myPrayerJournal
|
|
||||||
type DataContext =
|
|
||||||
inherit DbContext
|
|
||||||
|
|
||||||
(*--- CONSTRUCTORS ---*)
|
|
||||||
|
|
||||||
new () = { inherit DbContext () }
|
|
||||||
new (options : DbContextOptions<DataContext>) = { inherit DbContext (options) }
|
|
||||||
|
|
||||||
(*--- DbSet FIELDS ---*)
|
|
||||||
|
|
||||||
[<DefaultValue>]
|
|
||||||
val mutable private requests : DbSet<Request>
|
|
||||||
[<DefaultValue>]
|
|
||||||
val mutable private history : DbSet<History>
|
|
||||||
|
|
||||||
(*--- DbSet PROPERTIES ---*)
|
|
||||||
|
|
||||||
/// Prayer Requests
|
|
||||||
member this.Requests with get () = this.requests and set v = this.requests <- v
|
|
||||||
|
|
||||||
/// History
|
|
||||||
member this.History with get () = this.history and set v = this.history <- v
|
|
||||||
|
|
||||||
override this.OnModelCreating (modelBuilder) =
|
|
||||||
base.OnModelCreating modelBuilder
|
|
||||||
|
|
||||||
modelBuilder.HasDefaultSchema "mpj"
|
|
||||||
|> Request.ConfigureEF
|
|
||||||
|> History.ConfigureEF
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
/// Data access
|
|
||||||
module Data =
|
|
||||||
|
|
||||||
/// Data access for prayer requests
|
|
||||||
module Requests =
|
|
||||||
|
|
||||||
/// Get all prayer requests for a user
|
|
||||||
let allForUser userId (ctx : DataContext) =
|
|
||||||
query {
|
|
||||||
for req in ctx.Requests do
|
|
||||||
where (req.UserId = userId)
|
|
||||||
select req
|
|
||||||
}
|
|
||||||
|> Seq.sortBy
|
|
||||||
(fun req ->
|
|
||||||
match req.History |> Seq.sortBy (fun hist -> hist.AsOf) |> Seq.tryLast with
|
|
||||||
| Some hist -> hist.AsOf
|
|
||||||
| _ -> 0L)
|
|
||||||
|> List.ofSeq
|
|
@ -1,48 +0,0 @@
|
|||||||
namespace MyPrayerJournal
|
|
||||||
|
|
||||||
//open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
// -- begin code lifted from #er demo --
|
|
||||||
type ReaderM<'d, 'out> = 'd -> 'out
|
|
||||||
|
|
||||||
module Reader =
|
|
||||||
// basic operations
|
|
||||||
let run dep (rm : ReaderM<_,_>) = rm dep
|
|
||||||
let constant (c : 'c) : ReaderM<_,'c> = fun _ -> c
|
|
||||||
// lifting of functions and state
|
|
||||||
let lift1 (f : 'd -> 'a -> 'out) : 'a -> ReaderM<'d, 'out> = fun a dep -> f dep a
|
|
||||||
let lift2 (f : 'd -> 'a -> 'b -> 'out) : 'a -> 'b -> ReaderM<'d, 'out> = fun a b dep -> f dep a b
|
|
||||||
let lift3 (f : 'd -> 'a -> 'b -> 'c -> 'out) : 'a -> 'b -> 'c -> ReaderM<'d, 'out> = fun a b c dep -> f dep a b c
|
|
||||||
let liftDep (proj : 'd2 -> 'd1) (rm : ReaderM<'d1, 'output>) : ReaderM<'d2, 'output> = proj >> rm
|
|
||||||
// functor
|
|
||||||
let fmap (f : 'a -> 'b) (g : 'c -> 'a) : ('c -> 'b) = g >> f
|
|
||||||
let map (f : 'a -> 'b) (rm : ReaderM<'d, 'a>) : ReaderM<'d,'b> = rm >> f
|
|
||||||
let (<?>) = map
|
|
||||||
// applicative-functor
|
|
||||||
let apply (f : ReaderM<'d, 'a->'b>) (rm : ReaderM<'d, 'a>) : ReaderM<'d, 'b> =
|
|
||||||
fun dep ->
|
|
||||||
let f' = run dep f
|
|
||||||
let a = run dep rm
|
|
||||||
f' a
|
|
||||||
let (<*>) = apply
|
|
||||||
// monad
|
|
||||||
let bind (rm : ReaderM<'d, 'a>) (f : 'a -> ReaderM<'d,'b>) : ReaderM<'d, 'b> =
|
|
||||||
fun dep ->
|
|
||||||
f (rm dep)
|
|
||||||
|> run dep
|
|
||||||
let (>>=) = bind
|
|
||||||
type ReaderMBuilder internal () =
|
|
||||||
member __.Bind(m, f) = m >>= f
|
|
||||||
member __.Return(v) = constant v
|
|
||||||
member __.ReturnFrom(v) = v
|
|
||||||
member __.Delay(f) = f ()
|
|
||||||
let reader = ReaderMBuilder()
|
|
||||||
// -- end code lifted from #er demo --
|
|
||||||
|
|
||||||
(*type IDependencies =
|
|
||||||
abstract Conn : IConnection
|
|
||||||
|
|
||||||
[<AutoOpen>]
|
|
||||||
module DependencyExtraction =
|
|
||||||
|
|
||||||
let getConn (deps : IDependencies) = deps.Conn*)
|
|
@ -1,131 +0,0 @@
|
|||||||
namespace MyPrayerJournal
|
|
||||||
|
|
||||||
open Microsoft.EntityFrameworkCore;
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open System
|
|
||||||
open System.Collections.Generic
|
|
||||||
|
|
||||||
/// A prayer request
|
|
||||||
[<AllowNullLiteral>]
|
|
||||||
type Request() =
|
|
||||||
/// The history collection (can be overridden)
|
|
||||||
let mutable historyCollection : ICollection<History> = upcast List<History> ()
|
|
||||||
|
|
||||||
/// The Id of the prayer request
|
|
||||||
member val RequestId = Guid.Empty with get, set
|
|
||||||
/// The Id of the user to whom the request belongs
|
|
||||||
member val UserId = "" with get, set
|
|
||||||
/// The ticks when the request was entered
|
|
||||||
member val EnteredOn = 0L with get, set
|
|
||||||
|
|
||||||
/// The history for the prayer request
|
|
||||||
abstract History : ICollection<History> with get, set
|
|
||||||
default this.History
|
|
||||||
with get () = historyCollection
|
|
||||||
and set v = historyCollection <- v
|
|
||||||
|
|
||||||
static member ConfigureEF (mb : ModelBuilder) =
|
|
||||||
mb.Entity<Request>().ToTable "Request"
|
|
||||||
|> ignore
|
|
||||||
mb
|
|
||||||
|
|
||||||
|
|
||||||
/// A historial update to a prayer request
|
|
||||||
and [<AllowNullLiteral>] History() =
|
|
||||||
/// The request to which this entry applies (may be overridden)
|
|
||||||
let mutable request = null
|
|
||||||
|
|
||||||
/// The Id of the request to which this update applies
|
|
||||||
member val RequestId = Guid.Empty with get, set
|
|
||||||
/// The ticks when this entry was made
|
|
||||||
member val AsOf = 0L with get, set
|
|
||||||
/// The status of the request as of this history entry
|
|
||||||
member val Status = "" with get, set
|
|
||||||
/// The text of this history entry
|
|
||||||
member val Text = "" with get, set
|
|
||||||
|
|
||||||
/// The request to which this entry belongs
|
|
||||||
abstract Request : Request with get, set
|
|
||||||
default this.Request
|
|
||||||
with get () = request
|
|
||||||
and set v = request <- v
|
|
||||||
|
|
||||||
static member ConfigureEF (mb : ModelBuilder) =
|
|
||||||
mb.Entity<History>().ToTable("History")
|
|
||||||
|> ignore
|
|
||||||
mb.Entity<History>().HasKey(fun e -> (e.RequestId, e.AsOf) :> obj)
|
|
||||||
|> ignore
|
|
||||||
mb
|
|
||||||
|
|
||||||
(*
|
|
||||||
/// A user
|
|
||||||
type Userr = {
|
|
||||||
/// The Id of the user
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The user's e-mail address
|
|
||||||
Email : string
|
|
||||||
/// The user's name
|
|
||||||
Name : string
|
|
||||||
/// The time zone in which the user resides
|
|
||||||
TimeZone : string
|
|
||||||
/// The last time the user logged on
|
|
||||||
LastSeenOn : int64
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// An empty User
|
|
||||||
static member Empty =
|
|
||||||
{ Id = ""
|
|
||||||
Email = ""
|
|
||||||
Name = ""
|
|
||||||
TimeZone = ""
|
|
||||||
LastSeenOn = int64 0 }
|
|
||||||
|
|
||||||
|
|
||||||
/// Request history entry
|
|
||||||
type Historyy = {
|
|
||||||
/// The instant at which the update was made
|
|
||||||
AsOf : int64
|
|
||||||
/// The action that was taken on the request
|
|
||||||
Action : string list
|
|
||||||
/// The status of the request (filled if it changed)
|
|
||||||
Status : string option
|
|
||||||
/// The text of the request (filled if it changed)
|
|
||||||
Text : string option
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A prayer request
|
|
||||||
type Requestt = {
|
|
||||||
/// The Id of the request
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The Id of the user to whom this request belongs
|
|
||||||
UserId : string
|
|
||||||
/// The instant this request was entered
|
|
||||||
EnteredOn : int64
|
|
||||||
/// The history for this request
|
|
||||||
History : Historyy list
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// The current status of the prayer request
|
|
||||||
member this.Status =
|
|
||||||
this.History
|
|
||||||
|> List.sortBy (fun item -> -item.AsOf)
|
|
||||||
|> List.map (fun item -> item.Status)
|
|
||||||
|> List.filter Option.isSome
|
|
||||||
|> List.map Option.get
|
|
||||||
|> List.head
|
|
||||||
/// The current text of the prayer request
|
|
||||||
member this.Text =
|
|
||||||
this.History
|
|
||||||
|> List.sortBy (fun item -> -item.AsOf)
|
|
||||||
|> List.map (fun item -> item.Text)
|
|
||||||
|> List.filter Option.isSome
|
|
||||||
|> List.map Option.get
|
|
||||||
|> List.head
|
|
||||||
member this.LastActionOn =
|
|
||||||
this.History
|
|
||||||
|> List.sortBy (fun item -> -item.AsOf)
|
|
||||||
|> List.map (fun item -> item.AsOf)
|
|
||||||
|> List.head
|
|
||||||
*)
|
|
@ -1,16 +0,0 @@
|
|||||||
[<AutoOpen>]
|
|
||||||
module MyPrayerJournal.Extensions
|
|
||||||
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
// H/T: Suave
|
|
||||||
type AsyncBuilder with
|
|
||||||
/// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on
|
|
||||||
/// a standard .NET task
|
|
||||||
member x.Bind(t : Task<'T>, f:'T -> Async<'R>) : Async<'R> = async.Bind (Async.AwaitTask t, f)
|
|
||||||
|
|
||||||
/// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on
|
|
||||||
/// a standard .NET task which does not commpute a value
|
|
||||||
member x.Bind(t : Task, f : unit -> Async<'R>) : Async<'R> = async.Bind (Async.AwaitTask t, f)
|
|
||||||
|
|
||||||
member x.ReturnFrom(t : Task<'T>) : Async<'T> = Async.AwaitTask t
|
|
@ -1,87 +0,0 @@
|
|||||||
namespace MyPrayerJournal.Migrations
|
|
||||||
|
|
||||||
open System
|
|
||||||
open System.Collections.Generic
|
|
||||||
open Microsoft.EntityFrameworkCore
|
|
||||||
open Microsoft.EntityFrameworkCore.Infrastructure
|
|
||||||
open Microsoft.EntityFrameworkCore.Metadata
|
|
||||||
open Microsoft.EntityFrameworkCore.Migrations
|
|
||||||
open Microsoft.EntityFrameworkCore.Migrations.Operations
|
|
||||||
open Microsoft.EntityFrameworkCore.Migrations.Operations.Builders
|
|
||||||
open MyPrayerJournal
|
|
||||||
|
|
||||||
type RequestTable = {
|
|
||||||
RequestId : OperationBuilder<AddColumnOperation>
|
|
||||||
EnteredOn : OperationBuilder<AddColumnOperation>
|
|
||||||
UserId : OperationBuilder<AddColumnOperation>
|
|
||||||
}
|
|
||||||
|
|
||||||
type HistoryTable = {
|
|
||||||
RequestId : OperationBuilder<AddColumnOperation>
|
|
||||||
AsOf : OperationBuilder<AddColumnOperation>
|
|
||||||
Status : OperationBuilder<AddColumnOperation>
|
|
||||||
Text : OperationBuilder<AddColumnOperation>
|
|
||||||
}
|
|
||||||
|
|
||||||
[<DbContext (typeof<DataContext>)>]
|
|
||||||
[<Migration "20170104023341_InitialDb">]
|
|
||||||
type InitialDb () =
|
|
||||||
inherit Migration ()
|
|
||||||
|
|
||||||
override this.Up migrationBuilder =
|
|
||||||
migrationBuilder.EnsureSchema(
|
|
||||||
name = "mpj")
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name = "Request",
|
|
||||||
schema = "mpj",
|
|
||||||
columns =
|
|
||||||
(fun table ->
|
|
||||||
{ RequestId = table.Column<Guid>(nullable = false)
|
|
||||||
EnteredOn = table.Column<int64>(nullable = false)
|
|
||||||
UserId = table.Column<string>(nullable = false)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
constraints =
|
|
||||||
fun table ->
|
|
||||||
table.PrimaryKey("PK_Request", fun x -> x.RequestId :> obj) |> ignore
|
|
||||||
)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name = "History",
|
|
||||||
schema = "mpj",
|
|
||||||
columns =
|
|
||||||
(fun table ->
|
|
||||||
{ RequestId = table.Column<Guid>(nullable = false)
|
|
||||||
AsOf = table.Column<int64>(nullable = false)
|
|
||||||
Status = table.Column<string>(nullable = true)
|
|
||||||
Text = table.Column<string>(nullable = true)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
constraints =
|
|
||||||
fun table ->
|
|
||||||
table.PrimaryKey("PK_History", fun x -> (x.RequestId, x.AsOf) :> obj)
|
|
||||||
|> ignore
|
|
||||||
table.ForeignKey(
|
|
||||||
name = "FK_History_Request_RequestId",
|
|
||||||
column = (fun x -> x.RequestId :> obj),
|
|
||||||
principalSchema = "mpj",
|
|
||||||
principalTable = "Request",
|
|
||||||
principalColumn = "RequestId",
|
|
||||||
onDelete = ReferentialAction.Cascade)
|
|
||||||
|> ignore
|
|
||||||
)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
override this.Down migrationBuilder =
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name = "History",
|
|
||||||
schema = "mpj")
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name = "Request",
|
|
||||||
schema = "mpj")
|
|
||||||
|> ignore
|
|
@ -1,61 +0,0 @@
|
|||||||
namespace MyPrayerJournal.Migrations
|
|
||||||
|
|
||||||
open System
|
|
||||||
open Microsoft.EntityFrameworkCore
|
|
||||||
open Microsoft.EntityFrameworkCore.Infrastructure
|
|
||||||
open Microsoft.EntityFrameworkCore.Metadata
|
|
||||||
open Microsoft.EntityFrameworkCore.Migrations
|
|
||||||
open MyPrayerJournal
|
|
||||||
|
|
||||||
[<DbContext (typeof<DataContext>)>]
|
|
||||||
type DataContextModelSnapshot () =
|
|
||||||
inherit ModelSnapshot ()
|
|
||||||
override this.BuildModel modelBuilder =
|
|
||||||
modelBuilder
|
|
||||||
.HasDefaultSchema("mpj")
|
|
||||||
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn)
|
|
||||||
.HasAnnotation("ProductVersion", "1.1.0-rtm-22752")
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
modelBuilder.Entity("MyPrayerJournal.History",
|
|
||||||
fun b ->
|
|
||||||
b.Property<Guid>("RequestId")
|
|
||||||
|> ignore
|
|
||||||
b.Property<int64>("AsOf")
|
|
||||||
|> ignore
|
|
||||||
b.Property<string>("Status")
|
|
||||||
|> ignore
|
|
||||||
b.Property<string>("Text")
|
|
||||||
|> ignore
|
|
||||||
b.HasKey("RequestId", "AsOf")
|
|
||||||
|> ignore
|
|
||||||
b.ToTable("History")
|
|
||||||
|> ignore
|
|
||||||
)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
modelBuilder.Entity("MyPrayerJournal.Request",
|
|
||||||
fun b ->
|
|
||||||
b.Property<Guid>("RequestId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
|> ignore
|
|
||||||
b.Property<int64>("EnteredOn")
|
|
||||||
|> ignore
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
|> ignore
|
|
||||||
b.HasKey("RequestId")
|
|
||||||
|> ignore
|
|
||||||
b.ToTable("Request")
|
|
||||||
|> ignore
|
|
||||||
)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
modelBuilder.Entity("MyPrayerJournal.History",
|
|
||||||
fun b ->
|
|
||||||
b.HasOne("MyPrayerJournal.Request", "Request")
|
|
||||||
.WithMany("History")
|
|
||||||
.HasForeignKey("RequestId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
|> ignore
|
|
||||||
)
|
|
||||||
|> ignore
|
|
@ -1,30 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>netcoreapp2.0</TargetFramework>
|
|
||||||
<AssemblyName>myPrayerJournal</AssemblyName>
|
|
||||||
<PackageId>MyPrayerJournal</PackageId>
|
|
||||||
<VersionPrefix>0.8.1</VersionPrefix>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="Extensions.fs" />
|
|
||||||
<Compile Include="Entities.fs" />
|
|
||||||
<Compile Include="Dependencies.fs" />
|
|
||||||
<Compile Include="Data.fs" />
|
|
||||||
<Compile Include="Migrations/20170104023341_InitialDb.fs" />
|
|
||||||
<Compile Include="Migrations/DataContextModelSnapshot.fs" />
|
|
||||||
<Compile Include="App.fs" />
|
|
||||||
<None Include="appsettings.json" CopyToOutputDirectory="Always" />
|
|
||||||
<None Include="wwwroot/**" CopyToOutputDirectory="Always" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="FSharp.Core" Version="4.2.3" />
|
|
||||||
<PackageReference Include="jose-jwt" Version="2.*" />
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="10.*" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="1.*" />
|
|
||||||
<PackageReference Include="Suave" Version="2.*" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.*" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
35
src/api/app.js
Normal file
35
src/api/app.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const chalk = require('chalk')
|
||||||
|
|
||||||
|
const env = process.env.NODE_ENV || 'dev'
|
||||||
|
|
||||||
|
if ('dev' === env) require('babel-register')
|
||||||
|
|
||||||
|
const app = require('./index').default
|
||||||
|
const db = require('./db').default
|
||||||
|
const json = require('./json.mjs').default
|
||||||
|
|
||||||
|
const fullEnv = ('dev' === env) ? 'Development' : 'Production'
|
||||||
|
|
||||||
|
const { port } = json('./appsettings.json')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a start-up message for the app
|
||||||
|
* @param {string} status The status to display
|
||||||
|
*/
|
||||||
|
const startupMsg = (status) => {
|
||||||
|
console.log(chalk`{reset myPrayerJournal ${status} | Port: {bold ${port}} | Mode: {bold ${fullEnv}}}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the database exists before starting up
|
||||||
|
db.verify()
|
||||||
|
.then(() => app.listen(port, () => startupMsg('ready')))
|
||||||
|
.catch(err => {
|
||||||
|
console.log(chalk`\n{reset {bgRed.white.bold || Error connecting to PostgreSQL }}`)
|
||||||
|
for (let key of Object.keys(err)) {
|
||||||
|
console.log(chalk`${key}: {reset {bold ${err[key]}}}`)
|
||||||
|
}
|
||||||
|
console.log('')
|
||||||
|
startupMsg('failed')
|
||||||
|
})
|
70
src/api/db/ddl.js
Normal file
70
src/api/db/ddl.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL to check the existence of a table in the mpj schema
|
||||||
|
* @param {string} table The name of the table whose existence should be checked
|
||||||
|
*/
|
||||||
|
const tableSql = table => `SELECT 1 FROM pg_tables WHERE schemaname='mpj' AND tablename='${table}'`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL to determine if an index exists
|
||||||
|
* @param {string} table The name of the table which the given index indexes
|
||||||
|
* @param {string} index The name of the index
|
||||||
|
*/
|
||||||
|
const indexSql = (table, index) =>
|
||||||
|
`SELECT 1 FROM pg_indexes WHERE schemaname='mpj' AND tablename='${table}' AND indexname='${index}'`
|
||||||
|
|
||||||
|
const ddl = [
|
||||||
|
{
|
||||||
|
name: 'myPrayerJournal Schema',
|
||||||
|
check: `SELECT 1 FROM pg_namespace WHERE nspname='mpj'`,
|
||||||
|
fix: `
|
||||||
|
CREATE SCHEMA mpj;
|
||||||
|
COMMENT ON SCHEMA mpj IS 'myPrayerJournal data'`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'request Table',
|
||||||
|
check: tableSql('request'),
|
||||||
|
fix: `
|
||||||
|
CREATE TABLE mpj.request (
|
||||||
|
"requestId" varchar(25) PRIMARY KEY,
|
||||||
|
"enteredOn" bigint NOT NULL,
|
||||||
|
"userId" varchar(100) NOT NULL);
|
||||||
|
COMMENT ON TABLE mpj.request IS 'Requests'`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'history Table',
|
||||||
|
check: tableSql('history'),
|
||||||
|
fix: `
|
||||||
|
CREATE TABLE mpj.history (
|
||||||
|
"requestId" varchar(25) NOT NULL REFERENCES mpj.request,
|
||||||
|
"asOf" bigint NOT NULL,
|
||||||
|
"status" varchar(25),
|
||||||
|
"text" text,
|
||||||
|
PRIMARY KEY ("requestId", "asOf"));
|
||||||
|
COMMENT ON TABLE mpj.history IS 'Request update history'`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'request.userId Index',
|
||||||
|
check: indexSql('request', 'idx_request_userId'),
|
||||||
|
fix: `
|
||||||
|
CREATE INDEX "idx_request_userId" ON mpj.request ("userId");
|
||||||
|
COMMENT ON INDEX "idx_request_userId" IS 'Requests are retrieved by user'`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function (query) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Ensure that the database schema, tables, and indexes exist
|
||||||
|
*/
|
||||||
|
ensureDatabase: async () => {
|
||||||
|
for (let item of ddl) {
|
||||||
|
const result = await query(item.check, [])
|
||||||
|
if (1 > result.rowCount) await query(item.fix, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
src/api/db/index.js
Normal file
23
src/api/db/index.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
|
||||||
|
import appConfig from '../appsettings.json'
|
||||||
|
import ddl from './ddl'
|
||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/** Pooled PostgreSQL instance */
|
||||||
|
const pool = new Pool(appConfig.pgPool)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a SQL query
|
||||||
|
* @param {string} text The SQL command
|
||||||
|
* @param {*[]} params The parameters for the query
|
||||||
|
*/
|
||||||
|
const query = (text, params) => pool.query(text, params)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
query: query,
|
||||||
|
request: request(pool),
|
||||||
|
verify: ddl(query).ensureDatabase
|
||||||
|
}
|
65
src/api/db/request.js
Normal file
65
src/api/db/request.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
|
||||||
|
export default function (pool) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the current requests for a user (i.e., their complete current journal)
|
||||||
|
* @param {string} userId The Id of the user
|
||||||
|
* @return The requests that make up the current journal
|
||||||
|
*/
|
||||||
|
journal: async userId =>
|
||||||
|
(await pool.query({
|
||||||
|
name: 'journal',
|
||||||
|
text: `
|
||||||
|
SELECT
|
||||||
|
request."requestId",
|
||||||
|
(SELECT "text"
|
||||||
|
FROM mpj.history
|
||||||
|
WHERE history."requestId" = request."requestId"
|
||||||
|
AND "text" IS NOT NULL
|
||||||
|
ORDER BY "asOf" DESC
|
||||||
|
LIMIT 1) AS "text",
|
||||||
|
(SELECT "asOf"
|
||||||
|
FROM mpj.history
|
||||||
|
WHERE history."requestId" = request."requestId"
|
||||||
|
ORDER BY "asOf" DESC
|
||||||
|
LIMIT 1) AS "asOf"
|
||||||
|
FROM mpj.request
|
||||||
|
WHERE "userId" = $1
|
||||||
|
GROUP BY request."requestId"`
|
||||||
|
}, [userId])).rows,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new prayer request
|
||||||
|
* @param {string} userId The Id of the user
|
||||||
|
* @param {string} requestText The text of the request
|
||||||
|
* @return The created request
|
||||||
|
*/
|
||||||
|
addNew: async (userId, requestText) => {
|
||||||
|
const id = cuid()
|
||||||
|
const enteredOn = Date.now()
|
||||||
|
;(async () => {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO mpj.request ("requestId", "enteredOn", "userId") VALUES ($1, $2, $3)',
|
||||||
|
[ id, enteredOn, userId ])
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO mpj.history ("requestId", "asOf", "status", "text") VALUES ($1, $2, 'Created', $3)`,
|
||||||
|
[ id, enteredOn, requestText ])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
return { requestId: id, text: requestText, asOf: enteredOn }
|
||||||
|
})().catch(e => console.error(e.stack))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
src/api/index.js
Normal file
36
src/api/index.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import Koa from 'koa'
|
||||||
|
import bodyParser from 'koa-bodyparser'
|
||||||
|
import morgan from 'koa-morgan'
|
||||||
|
import send from 'koa-send'
|
||||||
|
import serveFrom from 'koa-static'
|
||||||
|
|
||||||
|
import appConfig from './appsettings.json'
|
||||||
|
import router from './routes'
|
||||||
|
|
||||||
|
/** Koa app */
|
||||||
|
const app = new Koa()
|
||||||
|
|
||||||
|
export default app
|
||||||
|
// Logging FTW!
|
||||||
|
.use(morgan('dev'))
|
||||||
|
// Serve the Vue files from /public
|
||||||
|
.use(serveFrom('public'))
|
||||||
|
// Parse the body into ctx.request.body, if present
|
||||||
|
.use(bodyParser())
|
||||||
|
// Tie in all the routes
|
||||||
|
.use(router.routes())
|
||||||
|
.use(router.allowedMethods())
|
||||||
|
// Send the index.html file for what would normally get a 404
|
||||||
|
.use(async (ctx, next) => {
|
||||||
|
if (ctx.url.indexOf('/api') === -1) {
|
||||||
|
try {
|
||||||
|
await send(ctx, 'index.html', { root: __dirname + '/public/' })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return await next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await next()
|
||||||
|
})
|
12
src/api/json.mjs
Normal file
12
src/api/json.mjs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse a JSON file
|
||||||
|
* @param {string} path The path to the file
|
||||||
|
* @param {string} encoding The encoding of the file (defaults to UTF-8)
|
||||||
|
* @return {*} The parsed contents of the file
|
||||||
|
*/
|
||||||
|
export default (path, encoding = 'utf-8') =>
|
||||||
|
JSON.parse(fs.readFileSync(path, encoding))
|
43
src/api/package.json
Normal file
43
src/api/package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "my-prayer-journal-api",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.8.0",
|
||||||
|
"description": "Server API for myPrayerJournal",
|
||||||
|
"main": "index.js",
|
||||||
|
"author": "Daniel J. Summers <daniel@djs-consulting.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^2.1.0",
|
||||||
|
"cuid": "^1.3.8",
|
||||||
|
"jwks-rsa-koa": "^1.1.3",
|
||||||
|
"koa": "^2.3.0",
|
||||||
|
"koa-bodyparser": "^4.2.0",
|
||||||
|
"koa-jwt": "^3.2.2",
|
||||||
|
"koa-morgan": "^1.0.1",
|
||||||
|
"koa-router": "^7.2.1",
|
||||||
|
"koa-send": "^4.1.0",
|
||||||
|
"koa-static": "^4.0.1",
|
||||||
|
"pg": "^7.3.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js",
|
||||||
|
"vue": "cd ../app && node build/build.js prod && cd ../api && node app.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"babel": "^6.23.0",
|
||||||
|
"babel-preset-env": "^1.6.0",
|
||||||
|
"babel-register": "^6.26.0"
|
||||||
|
},
|
||||||
|
"babel": {
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"env",
|
||||||
|
{
|
||||||
|
"targets": {
|
||||||
|
"node": "current"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
39
src/api/routes/index.js
Normal file
39
src/api/routes/index.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import jwt from 'koa-jwt'
|
||||||
|
import jwksRsa from 'jwks-rsa-koa'
|
||||||
|
import Router from 'koa-router'
|
||||||
|
|
||||||
|
import appConfig from '../appsettings.json'
|
||||||
|
import journal from './journal'
|
||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/** Authentication middleware to verify the access token against the Auth0 JSON Web Key Set */
|
||||||
|
const checkJwt = jwt({
|
||||||
|
// Dynamically provide a signing key
|
||||||
|
// based on the kid in the header and
|
||||||
|
// the singing keys provided by the JWKS endpoint.
|
||||||
|
secret: jwksRsa.koaJwt2Key({
|
||||||
|
cache: true,
|
||||||
|
rateLimit: true,
|
||||||
|
jwksRequestsPerMinute: 5,
|
||||||
|
jwksUri: `https://${appConfig.auth0.domain}/.well-known/jwks.json`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Validate the audience and the issuer.
|
||||||
|
audience: appConfig.auth0.clientId,
|
||||||
|
issuer: `https://${appConfig.auth0.domain}/`,
|
||||||
|
algorithms: ['RS256']
|
||||||
|
})
|
||||||
|
|
||||||
|
/** /api/journal routes */
|
||||||
|
const journalRoutes = journal(checkJwt)
|
||||||
|
/** /api/request routes */
|
||||||
|
const requestRoutes = request(checkJwt)
|
||||||
|
|
||||||
|
/** Combined router */
|
||||||
|
const router = new Router({ prefix: '/api' })
|
||||||
|
router.use('/journal', journalRoutes.routes(), journalRoutes.allowedMethods())
|
||||||
|
router.use('/request', requestRoutes.routes(), requestRoutes.allowedMethods())
|
||||||
|
|
||||||
|
export default router
|
16
src/api/routes/journal.js
Normal file
16
src/api/routes/journal.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import Router from 'koa-router'
|
||||||
|
import db from '../db'
|
||||||
|
|
||||||
|
const router = new Router()
|
||||||
|
|
||||||
|
export default function (checkJwt) {
|
||||||
|
|
||||||
|
router.get('/', checkJwt, async (ctx, next) => {
|
||||||
|
const reqs = await db.request.journal(ctx.state.user.sub)
|
||||||
|
ctx.body = reqs
|
||||||
|
return await next()
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
17
src/api/routes/request.js
Normal file
17
src/api/routes/request.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
import Router from 'koa-router'
|
||||||
|
import db from '../db'
|
||||||
|
|
||||||
|
const router = new Router()
|
||||||
|
|
||||||
|
export default function (checkJwt) {
|
||||||
|
|
||||||
|
router.post('/', checkJwt, async (ctx, next) => {
|
||||||
|
ctx.body = await db.request.addNew(ctx.state.user.sub, ctx.request.body.requestText)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
1612
src/api/yarn.lock
Normal file
1612
src/api/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,8 @@ var path = require('path')
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
build: {
|
build: {
|
||||||
env: require('./prod.env'),
|
env: require('./prod.env'),
|
||||||
index: path.resolve(__dirname, '../../api/wwwroot/index.html'),
|
index: path.resolve(__dirname, '../../api/public/index.html'),
|
||||||
assetsRoot: path.resolve(__dirname, '../../api/wwwroot'),
|
assetsRoot: path.resolve(__dirname, '../../api/public'),
|
||||||
assetsSubDirectory: 'static',
|
assetsSubDirectory: 'static',
|
||||||
assetsPublicPath: '/',
|
assetsPublicPath: '/',
|
||||||
productionSourceMap: true,
|
productionSourceMap: true,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
baseURL: 'http://localhost:8084/api'
|
baseURL: 'http://localhost:3000/api/'
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,16 +13,22 @@ export default {
|
|||||||
* Set the bearer token for all future requests
|
* Set the bearer token for all future requests
|
||||||
* @param {string} token The token to use to identify the user to the server
|
* @param {string} token The token to use to identify the user to the server
|
||||||
*/
|
*/
|
||||||
setBearer: token => { http.defaults.headers.common['Authentication'] = `Bearer ${token}` },
|
setBearer: token => { http.defaults.headers.common['authorization'] = `Bearer ${token}` },
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the bearer token
|
* Remove the bearer token
|
||||||
*/
|
*/
|
||||||
removeBearer: () => delete http.defaults.headers.common['Authentication'],
|
removeBearer: () => delete http.defaults.headers.common['authorization'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all prayer requests and their most recent updates
|
* Get all prayer requests and their most recent updates
|
||||||
*/
|
*/
|
||||||
journal: () => http.get('/journal')
|
journal: () => http.get('journal/'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new prayer request
|
||||||
|
* @param {string} requestText The text of the request to be added
|
||||||
|
*/
|
||||||
|
addRequest: requestText => http.post('request/', { requestText })
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import auth0 from 'auth0-js'
|
'use strict'
|
||||||
import { AUTH_CONFIG } from './auth0-variables'
|
|
||||||
|
|
||||||
import * as types from '@/store/mutation-types'
|
import auth0 from 'auth0-js'
|
||||||
|
|
||||||
|
import AUTH_CONFIG from './auth0-variables'
|
||||||
|
import mutations from '@/store/mutation-types'
|
||||||
|
|
||||||
export default class AuthService {
|
export default class AuthService {
|
||||||
|
|
||||||
@ -64,7 +66,7 @@ export default class AuthService {
|
|||||||
this.setSession(authResult)
|
this.setSession(authResult)
|
||||||
this.userInfo(authResult.accessToken)
|
this.userInfo(authResult.accessToken)
|
||||||
.then(user => {
|
.then(user => {
|
||||||
store.commit(types.USER_LOGGED_ON, user)
|
store.commit(mutations.USER_LOGGED_ON, user)
|
||||||
router.replace('/dashboard')
|
router.replace('/dashboard')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -93,7 +95,7 @@ export default class AuthService {
|
|||||||
localStorage.removeItem('expires_at')
|
localStorage.removeItem('expires_at')
|
||||||
localStorage.setItem('user_profile', JSON.stringify({}))
|
localStorage.setItem('user_profile', JSON.stringify({}))
|
||||||
// navigate to the home route
|
// navigate to the home route
|
||||||
store.commit(types.USER_LOGGED_OFF)
|
store.commit(mutations.USER_LOGGED_OFF)
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,16 +1,23 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(:title="title")
|
page-title(:title="title")
|
||||||
p here you are!
|
|
||||||
p(v-if="isLoadingJournal") journal is loading...
|
p(v-if="isLoadingJournal") journal is loading...
|
||||||
p(v-if="!isLoadingJournal") journal has {{ journal.length }} entries
|
template(v-if="!isLoadingJournal")
|
||||||
|
new-request
|
||||||
|
p journal has {{ journal.length }} entries
|
||||||
|
request-list-item(v-for="request in journal" v-bind:request="request" v-bind:key="request.requestId")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapState } from 'vuex'
|
'use strict'
|
||||||
import PageTitle from './PageTitle'
|
|
||||||
|
|
||||||
import * as actions from '@/store/action-types'
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
|
import PageTitle from './PageTitle'
|
||||||
|
import NewRequest from './request/NewRequest'
|
||||||
|
import RequestListItem from './request/RequestListItem'
|
||||||
|
|
||||||
|
import actions from '@/store/action-types'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
@ -19,7 +26,9 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
PageTitle
|
PageTitle,
|
||||||
|
NewRequest,
|
||||||
|
RequestListItem
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
title () {
|
title () {
|
||||||
|
36
src/app/src/components/request/NewRequest.vue
Normal file
36
src/app/src/components/request/NewRequest.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
div
|
||||||
|
el-button(@click='showNewVisible = true') Add a New Request
|
||||||
|
el-dialog(title='Add a New Prayer Request' :visible.sync='showNewVisible')
|
||||||
|
el-form(:model='form' :label-position='top')
|
||||||
|
el-form-item(label='Prayer Request')
|
||||||
|
el-input(type='textarea' v-model.trim='form.requestText' :rows='10')
|
||||||
|
span.dialog-footer(slot='footer')
|
||||||
|
el-button(@click='showNewVisible = false') Cancel
|
||||||
|
el-button(type='primary' @click='saveRequest()') Save
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
import actions from '@/store/action-types'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'new-request',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showNewVisible: false,
|
||||||
|
form: {
|
||||||
|
requestText: ''
|
||||||
|
},
|
||||||
|
formLabelWidth: '120px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
saveRequest: async function () {
|
||||||
|
await this.$store.dispatch(actions.ADD_REQUEST, this.form.requestText)
|
||||||
|
this.showNewVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
15
src/app/src/components/request/RequestListItem.vue
Normal file
15
src/app/src/components/request/RequestListItem.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
div
|
||||||
|
p Id {{ request.requestId }} as of {{ request.asOf }}
|
||||||
|
p {{ request.text }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'request-list-item',
|
||||||
|
props: ['request'],
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1 +1,8 @@
|
|||||||
export const LOAD_JOURNAL = 'load-journal'
|
'use strict'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/** Action to add a prayer request (pass request text) */
|
||||||
|
ADD_REQUEST: 'add-request',
|
||||||
|
/** Action to load the user's prayer journal */
|
||||||
|
LOAD_JOURNAL: 'load-journal'
|
||||||
|
}
|
||||||
|
@ -4,8 +4,8 @@ import Vuex from 'vuex'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import AuthService from '@/auth/AuthService'
|
import AuthService from '@/auth/AuthService'
|
||||||
|
|
||||||
import * as types from './mutation-types'
|
import mutations from './mutation-types'
|
||||||
import * as actions from './action-types'
|
import actions from './action-types'
|
||||||
|
|
||||||
Vue.use(Vuex)
|
Vue.use(Vuex)
|
||||||
|
|
||||||
@ -38,35 +38,48 @@ export default new Vuex.Store({
|
|||||||
isLoadingJournal: false
|
isLoadingJournal: false
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
[types.USER_LOGGED_ON] (state, user) {
|
[mutations.USER_LOGGED_ON] (state, user) {
|
||||||
localStorage.setItem('user_profile', JSON.stringify(user))
|
localStorage.setItem('user_profile', JSON.stringify(user))
|
||||||
state.user = user
|
state.user = user
|
||||||
|
api.setBearer(localStorage.getItem('id_token'))
|
||||||
state.isAuthenticated = true
|
state.isAuthenticated = true
|
||||||
},
|
},
|
||||||
[types.USER_LOGGED_OFF] (state) {
|
[mutations.USER_LOGGED_OFF] (state) {
|
||||||
state.user = {}
|
state.user = {}
|
||||||
|
api.removeBearer()
|
||||||
state.isAuthenticated = false
|
state.isAuthenticated = false
|
||||||
},
|
},
|
||||||
[types.LOADING_JOURNAL] (state, flag) {
|
[mutations.LOADING_JOURNAL] (state, flag) {
|
||||||
state.isLoadingJournal = flag
|
state.isLoadingJournal = flag
|
||||||
},
|
},
|
||||||
[types.LOADED_JOURNAL] (state, journal) {
|
[mutations.LOADED_JOURNAL] (state, journal) {
|
||||||
state.journal = journal
|
state.journal = journal
|
||||||
|
},
|
||||||
|
[mutations.REQUEST_ADDED] (state, newRequest) {
|
||||||
|
state.journal.unshift(newRequest)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
[actions.LOAD_JOURNAL] ({ commit }) {
|
async [actions.LOAD_JOURNAL] ({ commit }) {
|
||||||
commit(types.LOADED_JOURNAL, {})
|
commit(mutations.LOADED_JOURNAL, {})
|
||||||
commit(types.LOADING_JOURNAL, true)
|
commit(mutations.LOADING_JOURNAL, true)
|
||||||
api.journal()
|
api.setBearer(localStorage.getItem('id_token'))
|
||||||
.then(jrnl => {
|
try {
|
||||||
commit(types.LOADING_JOURNAL, false)
|
const jrnl = await api.journal()
|
||||||
commit(types.LOADED_JOURNAL, jrnl)
|
commit(mutations.LOADED_JOURNAL, jrnl.data)
|
||||||
})
|
} catch (err) {
|
||||||
.catch(err => {
|
logError(err)
|
||||||
commit(types.LOADING_JOURNAL, false)
|
} finally {
|
||||||
logError(err)
|
commit(mutations.LOADING_JOURNAL, false)
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
async [actions.ADD_REQUEST] ({ commit }, requestText) {
|
||||||
|
try {
|
||||||
|
const newRequest = await api.addRequest(requestText)
|
||||||
|
commit(mutations.REQUEST_ADDED, newRequest)
|
||||||
|
} catch (err) {
|
||||||
|
logError(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getters: {},
|
getters: {},
|
||||||
|
@ -1,4 +1,14 @@
|
|||||||
export const USER_LOGGED_OFF = 'user-logged-out'
|
'use strict'
|
||||||
export const USER_LOGGED_ON = 'user-logged-on'
|
|
||||||
export const LOADING_JOURNAL = 'loading-journal'
|
export default {
|
||||||
export const LOADED_JOURNAL = 'journal-loaded'
|
/** Mutation for when the user's prayer journal is being loaded */
|
||||||
|
LOADING_JOURNAL: 'loading-journal',
|
||||||
|
/** Mutation for when the user's prayer journal has been loaded */
|
||||||
|
LOADED_JOURNAL: 'journal-loaded',
|
||||||
|
/** Mutation for adding a new prayer request (pass text) */
|
||||||
|
REQUEST_ADDED: 'request-added',
|
||||||
|
/** Mutation for logging a user off */
|
||||||
|
USER_LOGGED_OFF: 'user-logged-off',
|
||||||
|
/** Mutation for logging a user on (pass user) */
|
||||||
|
USER_LOGGED_ON: 'user-logged-on'
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user