Merge branch 'master' of https://github.com/bit-badger/myPrayerJournal
This commit is contained in:
283
src/api/MyPrayerJournal.Api/Data.fs
Normal file
283
src/api/MyPrayerJournal.Api/Data.fs
Normal file
@@ -0,0 +1,283 @@
|
||||
namespace MyPrayerJournal
|
||||
|
||||
open FSharp.Control.Tasks.ContextInsensitive
|
||||
open Microsoft.EntityFrameworkCore
|
||||
|
||||
/// Helpers for this file
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
/// Convert any item to an option (Option.ofObj does not work for non-nullable types)
|
||||
let toOption<'T> (x : 'T) = match box x with null -> None | _ -> Some x
|
||||
|
||||
|
||||
/// Entities for use in the data model for myPrayerJournal
|
||||
[<AutoOpen>]
|
||||
module Entities =
|
||||
|
||||
open FSharp.EFCore.OptionConverter
|
||||
open System.Collections.Generic
|
||||
|
||||
/// Type alias for a Collision-resistant Unique IDentifier
|
||||
type Cuid = string
|
||||
|
||||
/// Request ID is a CUID
|
||||
type RequestId = Cuid
|
||||
|
||||
/// User ID is a string (the "sub" part of the JWT)
|
||||
type UserId = string
|
||||
|
||||
/// History is a record of action taken on a prayer request, including updates to its text
|
||||
type [<CLIMutable; NoComparison; NoEquality>] History =
|
||||
{ /// The ID of the request to which this history entry applies
|
||||
requestId : RequestId
|
||||
/// The time when this history entry was made
|
||||
asOf : int64
|
||||
/// The status for this history entry
|
||||
status : string
|
||||
/// The text of the update, if applicable
|
||||
text : string option
|
||||
}
|
||||
with
|
||||
/// An empty history entry
|
||||
static member empty =
|
||||
{ requestId = ""
|
||||
asOf = 0L
|
||||
status = ""
|
||||
text = None
|
||||
}
|
||||
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Entity<History> (
|
||||
fun m ->
|
||||
m.ToTable "history" |> ignore
|
||||
m.HasKey ("requestId", "asOf") |> ignore
|
||||
m.Property(fun e -> e.requestId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.asOf).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.status).IsRequired() |> ignore
|
||||
m.Property(fun e -> e.text) |> ignore)
|
||||
|> ignore
|
||||
let typ = mb.Model.FindEntityType(typeof<History>)
|
||||
let prop = typ.FindProperty("text")
|
||||
mb.Model.FindEntityType(typeof<History>).FindProperty("text").SetValueConverter (OptionConverter<string> ())
|
||||
|
||||
/// Note is a note regarding a prayer request that does not result in an update to its text
|
||||
and [<CLIMutable; NoComparison; NoEquality>] Note =
|
||||
{ /// The ID of the request to which this note applies
|
||||
requestId : RequestId
|
||||
/// The time when this note was made
|
||||
asOf : int64
|
||||
/// The text of the notes
|
||||
notes : string
|
||||
}
|
||||
with
|
||||
/// An empty note
|
||||
static member empty =
|
||||
{ requestId = ""
|
||||
asOf = 0L
|
||||
notes = ""
|
||||
}
|
||||
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Entity<Note> (
|
||||
fun m ->
|
||||
m.ToTable "note" |> ignore
|
||||
m.HasKey ("requestId", "asOf") |> ignore
|
||||
m.Property(fun e -> e.requestId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.asOf).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.notes).IsRequired () |> ignore)
|
||||
|> ignore
|
||||
|
||||
// Request is the identifying record for a prayer request.
|
||||
and [<CLIMutable; NoComparison; NoEquality>] Request =
|
||||
{ /// The ID of the request
|
||||
requestId : RequestId
|
||||
/// The time this request was initially entered
|
||||
enteredOn : int64
|
||||
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
||||
userId : string
|
||||
/// The time that this request should reappear in the user's journal
|
||||
snoozedUntil : int64
|
||||
/// The history entries for this request
|
||||
history : ICollection<History>
|
||||
/// The notes for this request
|
||||
notes : ICollection<Note>
|
||||
}
|
||||
with
|
||||
/// An empty request
|
||||
static member empty =
|
||||
{ requestId = ""
|
||||
enteredOn = 0L
|
||||
userId = ""
|
||||
snoozedUntil = 0L
|
||||
history = List<History> ()
|
||||
notes = List<Note> ()
|
||||
}
|
||||
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Entity<Request> (
|
||||
fun m ->
|
||||
m.ToTable "request" |> ignore
|
||||
m.HasKey(fun e -> e.requestId :> obj) |> ignore
|
||||
m.Property(fun e -> e.requestId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.enteredOn).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.userId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.snoozedUntil).IsRequired () |> ignore
|
||||
m.HasMany(fun e -> e.history :> IEnumerable<History>)
|
||||
.WithOne()
|
||||
.HasForeignKey(fun e -> e.requestId :> obj)
|
||||
|> ignore
|
||||
m.HasMany(fun e -> e.notes :> IEnumerable<Note>)
|
||||
.WithOne()
|
||||
.HasForeignKey(fun e -> e.requestId :> obj)
|
||||
|> ignore)
|
||||
|> ignore
|
||||
|
||||
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
||||
/// properties that may be filled for history and notes
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type JournalRequest =
|
||||
{ /// The ID of the request
|
||||
requestId : RequestId
|
||||
/// The ID of the user to whom the request belongs
|
||||
userId : string
|
||||
/// The current text of the request
|
||||
text : string
|
||||
/// The last time action was taken on the request
|
||||
asOf : int64
|
||||
/// The last status for the request
|
||||
lastStatus : string
|
||||
/// The time that this request should reappear in the user's journal
|
||||
snoozedUntil : int64
|
||||
/// History entries for the request
|
||||
history : History list
|
||||
/// Note entries for the request
|
||||
notes : Note list
|
||||
}
|
||||
with
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Query<JournalRequest> (
|
||||
fun m ->
|
||||
m.ToView "journal" |> ignore
|
||||
m.Ignore(fun e -> e.history :> obj) |> ignore
|
||||
m.Ignore(fun e -> e.notes :> obj) |> ignore)
|
||||
|> ignore
|
||||
|
||||
|
||||
open System.Linq
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Data context
|
||||
type AppDbContext (opts : DbContextOptions<AppDbContext>) =
|
||||
inherit DbContext (opts)
|
||||
|
||||
[<DefaultValue>]
|
||||
val mutable private history : DbSet<History>
|
||||
[<DefaultValue>]
|
||||
val mutable private notes : DbSet<Note>
|
||||
[<DefaultValue>]
|
||||
val mutable private requests : DbSet<Request>
|
||||
[<DefaultValue>]
|
||||
val mutable private journal : DbQuery<JournalRequest>
|
||||
|
||||
member this.History
|
||||
with get () = this.history
|
||||
and set v = this.history <- v
|
||||
member this.Notes
|
||||
with get () = this.notes
|
||||
and set v = this.notes <- v
|
||||
member this.Requests
|
||||
with get () = this.requests
|
||||
and set v = this.requests <- v
|
||||
member this.Journal
|
||||
with get () = this.journal
|
||||
and set v = this.journal <- v
|
||||
|
||||
override __.OnModelCreating (mb : ModelBuilder) =
|
||||
base.OnModelCreating mb
|
||||
[ History.configureEF
|
||||
Note.configureEF
|
||||
Request.configureEF
|
||||
JournalRequest.configureEF
|
||||
]
|
||||
|> List.iter (fun x -> x mb)
|
||||
|
||||
/// Register a disconnected entity with the context, having the given state
|
||||
member private this.RegisterAs<'TEntity when 'TEntity : not struct> state e =
|
||||
this.Entry<'TEntity>(e).State <- state
|
||||
|
||||
/// Add an entity instance to the context
|
||||
member this.AddEntry e =
|
||||
this.RegisterAs EntityState.Added e
|
||||
|
||||
/// Update the entity instance's values
|
||||
member this.UpdateEntry e =
|
||||
this.RegisterAs EntityState.Modified e
|
||||
|
||||
/// Retrieve all answered requests for the given user
|
||||
member this.AnsweredRequests userId : JournalRequest seq =
|
||||
upcast this.Journal
|
||||
.Where(fun r -> r.userId = userId && r.lastStatus = "Answered")
|
||||
.OrderByDescending(fun r -> r.asOf)
|
||||
|
||||
/// Retrieve the user's current journal
|
||||
member this.JournalByUserId userId : JournalRequest seq =
|
||||
upcast this.Journal
|
||||
.Where(fun r -> r.userId = userId && r.lastStatus <> "Answered")
|
||||
.OrderBy(fun r -> r.asOf)
|
||||
|
||||
/// Retrieve a request by its ID and user ID
|
||||
member this.TryRequestById reqId userId : Task<Request option> =
|
||||
task {
|
||||
let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId)
|
||||
return toOption req
|
||||
}
|
||||
|
||||
/// Retrieve notes for a request by its ID and user ID
|
||||
member this.NotesById reqId userId =
|
||||
task {
|
||||
let! req = this.TryRequestById reqId userId
|
||||
match req with
|
||||
| Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq
|
||||
| None -> return []
|
||||
}
|
||||
|
||||
/// Retrieve a journal request by its ID and user ID
|
||||
member this.TryJournalById reqId userId =
|
||||
task {
|
||||
let! req = this.Journal.FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId)
|
||||
return toOption req
|
||||
}
|
||||
|
||||
/// Retrieve a request, including its history and notes, by its ID and user ID
|
||||
member this.TryCompleteRequestById requestId userId =
|
||||
task {
|
||||
let! req = this.TryJournalById requestId userId
|
||||
match req with
|
||||
| Some r ->
|
||||
let! fullReq =
|
||||
this.Requests.AsNoTracking()
|
||||
.Include(fun r -> r.history)
|
||||
.Include(fun r -> r.notes)
|
||||
.FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId)
|
||||
match toOption fullReq with
|
||||
| Some _ -> return Some { r with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes }
|
||||
| None -> return None
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Retrieve a request, including its history, by its ID and user ID
|
||||
member this.TryFullRequestById requestId userId =
|
||||
task {
|
||||
let! req = this.TryJournalById requestId userId
|
||||
match req with
|
||||
| Some r ->
|
||||
let! fullReq =
|
||||
this.Requests.AsNoTracking()
|
||||
.Include(fun r -> r.history)
|
||||
.FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId)
|
||||
match toOption fullReq with
|
||||
| Some _ -> return Some { r with history = List.ofSeq fullReq.history }
|
||||
| None -> return None
|
||||
| None -> return None
|
||||
}
|
||||
261
src/api/MyPrayerJournal.Api/Handlers.fs
Normal file
261
src/api/MyPrayerJournal.Api/Handlers.fs
Normal file
@@ -0,0 +1,261 @@
|
||||
/// HTTP handlers for the myPrayerJournal API
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyPrayerJournal.Api.Handlers
|
||||
|
||||
open Giraffe
|
||||
open MyPrayerJournal
|
||||
open System
|
||||
|
||||
module Error =
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Handle errors
|
||||
let error (ex : Exception) (log : ILogger) =
|
||||
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
||||
clearResponse >=> setStatusCode 500 >=> json ex.Message
|
||||
|
||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||
let notFound : HttpHandler =
|
||||
fun next ctx ->
|
||||
[ "/answered"; "/journal"; "/snoozed"; "/user" ]
|
||||
|> List.filter ctx.Request.Path.Value.StartsWith
|
||||
|> List.length
|
||||
|> function
|
||||
| 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx
|
||||
| _ -> htmlFile "wwwroot/index.html" next ctx
|
||||
|
||||
|
||||
/// Handler helpers
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open System.Threading.Tasks
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the database context from DI
|
||||
let db (ctx : HttpContext) =
|
||||
ctx.GetService<AppDbContext> ()
|
||||
|
||||
/// Get the user's "sub" claim
|
||||
let user (ctx : HttpContext) =
|
||||
ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier)
|
||||
|
||||
/// Get the current user's ID
|
||||
// NOTE: this may raise if you don't run the request through the authorize handler first
|
||||
let userId ctx =
|
||||
((user >> Option.get) ctx).Value
|
||||
|
||||
/// Return a 201 CREATED response
|
||||
let created next ctx =
|
||||
setStatusCode 201 next ctx
|
||||
|
||||
/// The "now" time in JavaScript
|
||||
let jsNow () =
|
||||
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> int64 |> (*) 1000L
|
||||
|
||||
/// Handler to return a 403 Not Authorized reponse
|
||||
let notAuthorized : HttpHandler =
|
||||
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||
|
||||
/// Handler to require authorization
|
||||
let authorize : HttpHandler =
|
||||
fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
|
||||
|
||||
/// Flip JSON result so we can pipe into it
|
||||
let asJson<'T> next ctx (o : 'T) =
|
||||
json o next ctx
|
||||
|
||||
|
||||
/// Strongly-typed models for post requests
|
||||
module Models =
|
||||
|
||||
/// A history entry addition (AKA request update)
|
||||
[<CLIMutable>]
|
||||
type HistoryEntry =
|
||||
{ /// The status of the history update
|
||||
status : string
|
||||
/// The text of the update
|
||||
updateText : string
|
||||
}
|
||||
|
||||
/// An additional note
|
||||
[<CLIMutable>]
|
||||
type NoteEntry =
|
||||
{ /// The notes being added
|
||||
notes : string
|
||||
}
|
||||
|
||||
/// A prayer request
|
||||
[<CLIMutable>]
|
||||
type Request =
|
||||
{ /// The text of the request
|
||||
requestText : string
|
||||
}
|
||||
|
||||
/// The time until which a request should not appear in the journal
|
||||
[<CLIMutable>]
|
||||
type SnoozeUntil =
|
||||
{ /// The time at which the request should reappear
|
||||
until : int64
|
||||
}
|
||||
|
||||
|
||||
/// /api/journal URLs
|
||||
module Journal =
|
||||
|
||||
/// GET /api/journal
|
||||
let journal : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
userId ctx
|
||||
|> (db ctx).JournalByUserId
|
||||
|> asJson next ctx
|
||||
|
||||
|
||||
/// /api/request URLs
|
||||
module Request =
|
||||
|
||||
open NCuid
|
||||
|
||||
/// POST /api/request
|
||||
let add : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! r = ctx.BindJsonAsync<Models.Request> ()
|
||||
let db = db ctx
|
||||
let reqId = Cuid.Generate ()
|
||||
let usrId = userId ctx
|
||||
let now = jsNow ()
|
||||
{ Request.empty with
|
||||
requestId = reqId
|
||||
userId = usrId
|
||||
enteredOn = now
|
||||
snoozedUntil = 0L
|
||||
}
|
||||
|> db.AddEntry
|
||||
{ History.empty with
|
||||
requestId = reqId
|
||||
asOf = now
|
||||
status = "Created"
|
||||
text = Some r.requestText
|
||||
}
|
||||
|> db.AddEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
let! req = db.TryJournalById reqId usrId
|
||||
match req with
|
||||
| Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// POST /api/request/[req-id]/history
|
||||
let addHistory reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
let! req = db.TryRequestById reqId (userId ctx)
|
||||
match req with
|
||||
| Some _ ->
|
||||
let! hist = ctx.BindJsonAsync<Models.HistoryEntry> ()
|
||||
{ History.empty with
|
||||
requestId = reqId
|
||||
asOf = jsNow ()
|
||||
status = hist.status
|
||||
text = match hist.updateText with null | "" -> None | x -> Some x
|
||||
}
|
||||
|> db.AddEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
return! created next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// POST /api/request/[req-id]/note
|
||||
let addNote reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
let! req = db.TryRequestById reqId (userId ctx)
|
||||
match req with
|
||||
| Some _ ->
|
||||
let! notes = ctx.BindJsonAsync<Models.NoteEntry> ()
|
||||
{ Note.empty with
|
||||
requestId = reqId
|
||||
asOf = jsNow ()
|
||||
notes = notes.notes
|
||||
}
|
||||
|> db.AddEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
return! created next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// GET /api/requests/answered
|
||||
let answered : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
userId ctx
|
||||
|> (db ctx).AnsweredRequests
|
||||
|> asJson next ctx
|
||||
|
||||
/// GET /api/request/[req-id]
|
||||
let get reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! req = (db ctx).TryJournalById reqId (userId ctx)
|
||||
match req with
|
||||
| Some r -> return! json r next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// GET /api/request/[req-id]/complete
|
||||
let getComplete reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! req = (db ctx).TryCompleteRequestById reqId (userId ctx)
|
||||
match req with
|
||||
| Some r -> return! json r next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// GET /api/request/[req-id]/full
|
||||
let getFull reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! req = (db ctx).TryFullRequestById reqId (userId ctx)
|
||||
match req with
|
||||
| Some r -> return! json r next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// GET /api/request/[req-id]/notes
|
||||
let getNotes reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! notes = (db ctx).NotesById reqId (userId ctx)
|
||||
return! json notes next ctx
|
||||
}
|
||||
|
||||
/// POST /api/request/[req-id]/snooze
|
||||
let snooze reqId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
let! req = db.TryRequestById reqId (userId ctx)
|
||||
match req with
|
||||
| Some r ->
|
||||
let! until = ctx.BindJsonAsync<Models.SnoozeUntil> ()
|
||||
{ r with snoozedUntil = until.until }
|
||||
|> db.UpdateEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
return! setStatusCode 204 next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
30
src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj
Normal file
30
src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FSharp.EFCore.OptionConverter" Version="1.0.0" />
|
||||
<PackageReference Include="Giraffe" Version="1.1.0" />
|
||||
<PackageReference Include="Giraffe.TokenRouter" Version="0.1.0-beta-110" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="NCuid.NetCore" Version="1.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.1.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="4.5.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
121
src/api/MyPrayerJournal.Api/Program.fs
Normal file
121
src/api/MyPrayerJournal.Api/Program.fs
Normal file
@@ -0,0 +1,121 @@
|
||||
namespace MyPrayerJournal.Api
|
||||
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Hosting
|
||||
open System
|
||||
|
||||
|
||||
/// Configuration functions for the application
|
||||
module Configure =
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.TokenRouter
|
||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
||||
open Microsoft.EntityFrameworkCore
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyPrayerJournal
|
||||
|
||||
/// Set up the configuration for the app
|
||||
let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) =
|
||||
cfg.SetBasePath(ctx.HostingEnvironment.ContentRootPath)
|
||||
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
|
||||
.AddJsonFile(sprintf "appsettings.%s.json" ctx.HostingEnvironment.EnvironmentName)
|
||||
.AddEnvironmentVariables()
|
||||
|> ignore
|
||||
|
||||
/// Configure Kestrel from appsettings.json
|
||||
let kestrel (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) =
|
||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
||||
|
||||
/// Configure dependency injection
|
||||
let services (sc : IServiceCollection) =
|
||||
use sp = sc.BuildServiceProvider()
|
||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||
sc.AddGiraffe()
|
||||
.AddAuthentication(
|
||||
/// Use HTTP "Bearer" authentication with JWTs
|
||||
fun opts ->
|
||||
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(
|
||||
/// Configure JWT options with Auth0 options from configuration
|
||||
fun opts ->
|
||||
let jwtCfg = cfg.GetSection "Auth0"
|
||||
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
|
||||
opts.Audience <- jwtCfg.["Id"])
|
||||
|> ignore
|
||||
sc.AddDbContext<AppDbContext>(fun opts -> opts.UseNpgsql(cfg.GetConnectionString "mpj") |> ignore)
|
||||
|> ignore
|
||||
|
||||
/// Routes for the available URLs within myPrayerJournal
|
||||
let webApp =
|
||||
router Handlers.Error.notFound [
|
||||
subRoute "/api/" [
|
||||
GET [
|
||||
route "journal" Handlers.Journal.journal
|
||||
subRoute "request" [
|
||||
route "s/answered" Handlers.Request.answered
|
||||
routef "/%s/complete" Handlers.Request.getComplete
|
||||
routef "/%s/full" Handlers.Request.getFull
|
||||
routef "/%s/notes" Handlers.Request.getNotes
|
||||
routef "/%s" Handlers.Request.get
|
||||
]
|
||||
]
|
||||
POST [
|
||||
subRoute "request" [
|
||||
route "" Handlers.Request.add
|
||||
routef "/%s/history" Handlers.Request.addHistory
|
||||
routef "/%s/note" Handlers.Request.addNote
|
||||
routef "/%s/snooze" Handlers.Request.snooze
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Configure the web application
|
||||
let application (app : IApplicationBuilder) =
|
||||
let env = app.ApplicationServices.GetService<IHostingEnvironment> ()
|
||||
match env.IsDevelopment () with
|
||||
| true -> app.UseDeveloperExceptionPage ()
|
||||
| false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
||||
|> function
|
||||
| a ->
|
||||
a.UseAuthentication()
|
||||
.UseStaticFiles()
|
||||
.UseGiraffe webApp
|
||||
|> ignore
|
||||
|
||||
/// Configure logging
|
||||
let logging (log : ILoggingBuilder) =
|
||||
let env = log.Services.BuildServiceProvider().GetService<IHostingEnvironment> ()
|
||||
match env.IsDevelopment () with
|
||||
| true -> log
|
||||
| false -> log.AddFilter(fun l -> l > LogLevel.Information)
|
||||
|> function l -> l.AddConsole().AddDebug()
|
||||
|> ignore
|
||||
|
||||
|
||||
module Program =
|
||||
|
||||
open System.IO
|
||||
|
||||
let exitCode = 0
|
||||
|
||||
let CreateWebHostBuilder _ =
|
||||
let contentRoot = Directory.GetCurrentDirectory ()
|
||||
WebHostBuilder()
|
||||
.UseContentRoot(contentRoot)
|
||||
.ConfigureAppConfiguration(Configure.configuration)
|
||||
.UseKestrel(Configure.kestrel)
|
||||
.UseWebRoot(Path.Combine (contentRoot, "wwwroot"))
|
||||
.ConfigureServices(Configure.services)
|
||||
.ConfigureLogging(Configure.logging)
|
||||
.Configure(Action<IApplicationBuilder> Configure.application)
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
CreateWebHostBuilder(args).Build().Run()
|
||||
exitCode
|
||||
27
src/api/MyPrayerJournal.Api/Properties/launchSettings.json
Normal file
27
src/api/MyPrayerJournal.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:61905",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"MyPrayerJournal.Api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/api/MyPrayerJournal.Api/appsettings.json
Normal file
9
src/api/MyPrayerJournal.Api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Kestrel": {
|
||||
"EndPoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/api/MyPrayerJournal.sln
Normal file
25
src/api/MyPrayerJournal.sln
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.27703.2035
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{E0E5240C-00DC-428A-899A-DA4F06625B8A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {7EAB6243-94B3-49A5-BA64-7F01B8BE7CB9}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,392 +0,0 @@
|
||||
// Package data contains data access functions for myPrayerJournal.
|
||||
package data
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
// Register the PostgreSQL driver.
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/lucsky/cuid"
|
||||
)
|
||||
|
||||
const (
|
||||
currentRequestSQL = `
|
||||
SELECT "requestId", "text", "asOf", "lastStatus", "snoozedUntil"
|
||||
FROM mpj.journal`
|
||||
journalSQL = `
|
||||
SELECT "requestId", "text", "asOf", "lastStatus", "snoozedUntil"
|
||||
FROM mpj.journal
|
||||
WHERE "userId" = $1
|
||||
AND "lastStatus" <> 'Answered'`
|
||||
)
|
||||
|
||||
// db is a connection to the database for the entire application.
|
||||
var db *sql.DB
|
||||
|
||||
// Settings holds the PostgreSQL configuration for myPrayerJournal.
|
||||
type Settings struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DbName string `json:"dbname"`
|
||||
}
|
||||
|
||||
/* Data Access */
|
||||
|
||||
// Retrieve a basic request
|
||||
func retrieveRequest(reqID, userID string) (*Request, bool) {
|
||||
req := Request{}
|
||||
err := db.QueryRow(`
|
||||
SELECT "requestId", "enteredOn", "snoozedUntil"
|
||||
FROM mpj.request
|
||||
WHERE "requestId" = $1
|
||||
AND "userId" = $2`, reqID, userID).Scan(
|
||||
&req.ID, &req.EnteredOn, &req.SnoozedUntil,
|
||||
)
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Print(err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
req.UserID = userID
|
||||
return &req, true
|
||||
}
|
||||
|
||||
// Unix time in JavaScript Date.now() precision.
|
||||
func jsNow() int64 {
|
||||
return time.Now().UnixNano() / int64(1000000)
|
||||
}
|
||||
|
||||
// Loop through rows and create journal requests from them.
|
||||
func makeJournal(rows *sql.Rows, userID string) []JournalRequest {
|
||||
var out []JournalRequest
|
||||
for rows.Next() {
|
||||
req := JournalRequest{}
|
||||
err := rows.Scan(&req.RequestID, &req.Text, &req.AsOf, &req.LastStatus, &req.SnoozedUntil)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
continue
|
||||
}
|
||||
out = append(out, req)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
log.Print(rows.Err())
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AddHistory creates a history entry for a prayer request, given the status and updated text.
|
||||
func AddHistory(userID, reqID, status, text string) int {
|
||||
if _, ok := retrieveRequest(reqID, userID); !ok {
|
||||
return 404
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO mpj.history
|
||||
("requestId", "asOf", "status", "text")
|
||||
VALUES
|
||||
($1, $2, $3, NULLIF($4, ''))`,
|
||||
reqID, jsNow(), status, text)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 500
|
||||
}
|
||||
return 204
|
||||
}
|
||||
|
||||
// AddNew stores a new prayer request and its initial history record.
|
||||
func AddNew(userID, text string) (*JournalRequest, bool) {
|
||||
id := cuid.New()
|
||||
now := jsNow()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil, false
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
tx.Rollback()
|
||||
} else {
|
||||
tx.Commit()
|
||||
}
|
||||
}()
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO mpj.request ("requestId", "enteredOn", "userId", "snoozedUntil") VALUES ($1, $2, $3, 0)`,
|
||||
id, now, userID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO mpj.history ("requestId", "asOf", "status", "text") VALUES ($1, $2, 'Created', $3)`,
|
||||
id, now, text)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &JournalRequest{RequestID: id, Text: text, AsOf: now, LastStatus: `Created`, SnoozedUntil: 0}, true
|
||||
}
|
||||
|
||||
// AddNote adds a note to a prayer request.
|
||||
func AddNote(userID, reqID, note string) int {
|
||||
if _, ok := retrieveRequest(reqID, userID); !ok {
|
||||
return 404
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO mpj.note
|
||||
("requestId", "asOf", "notes")
|
||||
VALUES
|
||||
($1, $2, $3)`,
|
||||
reqID, jsNow(), note)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 500
|
||||
}
|
||||
return 204
|
||||
}
|
||||
|
||||
// Answered retrieves all answered requests for the given user.
|
||||
func Answered(userID string) []JournalRequest {
|
||||
rows, err := db.Query(currentRequestSQL+
|
||||
` WHERE "userId" = $1
|
||||
AND "lastStatus" = 'Answered'
|
||||
ORDER BY "asOf" DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
return makeJournal(rows, userID)
|
||||
}
|
||||
|
||||
// ByID retrieves a journal request by its ID.
|
||||
func ByID(userID, reqID string) (*JournalRequest, bool) {
|
||||
req := JournalRequest{}
|
||||
err := db.QueryRow(currentRequestSQL+
|
||||
` WHERE "requestId" = $1
|
||||
AND "userId" = $2`,
|
||||
reqID, userID).Scan(
|
||||
&req.RequestID, &req.Text, &req.AsOf, &req.LastStatus, &req.SnoozedUntil,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, true
|
||||
}
|
||||
log.Print(err)
|
||||
return nil, false
|
||||
}
|
||||
return &req, true
|
||||
}
|
||||
|
||||
// Connect establishes a connection to the database.
|
||||
func Connect(s *Settings) bool {
|
||||
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
s.Host, s.Port, s.User, s.Password, s.DbName)
|
||||
var err error
|
||||
db, err = sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
log.Printf("Connected to postgres://%s@%s:%d/%s\n", s.User, s.Host, s.Port, s.DbName)
|
||||
return true
|
||||
}
|
||||
|
||||
// FullByID retrieves a journal request, including its full history and notes.
|
||||
func FullByID(userID, reqID string) (*JournalRequest, bool) {
|
||||
req, ok := ByID(userID, reqID)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
hRows, err := db.Query(`
|
||||
SELECT "asOf", "status", COALESCE("text", '') AS "text"
|
||||
FROM mpj.history
|
||||
WHERE "requestId" = $1
|
||||
ORDER BY "asOf"`,
|
||||
reqID)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil, false
|
||||
}
|
||||
defer hRows.Close()
|
||||
for hRows.Next() {
|
||||
hist := History{}
|
||||
err = hRows.Scan(&hist.AsOf, &hist.Status, &hist.Text)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
continue
|
||||
}
|
||||
req.History = append(req.History, hist)
|
||||
}
|
||||
if hRows.Err() != nil {
|
||||
log.Print(hRows.Err())
|
||||
return nil, false
|
||||
}
|
||||
req.Notes, err = NotesByID(userID, reqID)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil, false
|
||||
}
|
||||
return req, true
|
||||
}
|
||||
|
||||
// Journal retrieves the current user's active prayer journal.
|
||||
func Journal(userID string) []JournalRequest {
|
||||
rows, err := db.Query(journalSQL+` ORDER BY "asOf"`, userID)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
return makeJournal(rows, userID)
|
||||
}
|
||||
|
||||
// NotesByID retrieves the notes for a given prayer request
|
||||
func NotesByID(userID, reqID string) ([]Note, error) {
|
||||
if _, ok := retrieveRequest(reqID, userID); !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT "asOf", "notes"
|
||||
FROM mpj.note
|
||||
WHERE "requestId" = $1
|
||||
ORDER BY "asOf" DESC`,
|
||||
reqID)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var notes []Note
|
||||
for rows.Next() {
|
||||
note := Note{}
|
||||
err = rows.Scan(¬e.AsOf, ¬e.Notes)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
continue
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
log.Print(rows.Err())
|
||||
return nil, err
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
// SnoozeByID sets a request to not show until a specified time
|
||||
func SnoozeByID(userID, reqID string, until int64) int {
|
||||
if _, ok := retrieveRequest(reqID, userID); !ok {
|
||||
return 404
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
UPDATE mpj.request
|
||||
SET "snoozedUntil" = $2
|
||||
WHERE "requestId" = $1`,
|
||||
reqID, until)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 500
|
||||
}
|
||||
return 204
|
||||
}
|
||||
|
||||
/* DDL */
|
||||
|
||||
// EnsureDB makes sure we have a known state of data structures.
|
||||
func EnsureDB() {
|
||||
tableSQL := func(table string) string {
|
||||
return fmt.Sprintf(`SELECT 1 FROM pg_tables WHERE schemaname='mpj' AND tablename='%s'`, table)
|
||||
}
|
||||
columnSQL := func(table, column string) string {
|
||||
return fmt.Sprintf(
|
||||
`SELECT 1 FROM information_schema.columns WHERE table_schema='mpj' AND table_name='%s' AND column_name='%s'`,
|
||||
table, column)
|
||||
}
|
||||
indexSQL := func(table, index string) string {
|
||||
return fmt.Sprintf(`SELECT 1 FROM pg_indexes WHERE schemaname='mpj' AND tablename='%s' AND indexname='%s'`,
|
||||
table, index)
|
||||
}
|
||||
check := func(name, test, fix string) {
|
||||
count := 0
|
||||
err := db.QueryRow(test).Scan(&count)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("Fixing up %s...\n", name)
|
||||
_, err = db.Exec(fix)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
check(`myPrayerJournal Schema`, `SELECT 1 FROM pg_namespace WHERE nspname='mpj'`,
|
||||
`CREATE SCHEMA mpj;
|
||||
COMMENT ON SCHEMA mpj IS 'myPrayerJournal data'`)
|
||||
if _, err := db.Exec(`SET search_path TO mpj`); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
check(`request Table`, tableSQL(`request`),
|
||||
`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'`)
|
||||
check(`request.snoozedUntil Column`, columnSQL(`request`, `snoozedUntil`),
|
||||
`ALTER TABLE mpj.request
|
||||
ADD COLUMN "snoozedUntil" bigint NOT NULL DEFAULT 0`)
|
||||
check(`history Table`, tableSQL(`history`),
|
||||
`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'`)
|
||||
check(`note Table`, tableSQL(`note`),
|
||||
`CREATE TABLE mpj.note (
|
||||
"requestId" varchar(25) NOT NULL REFERENCES mpj.request,
|
||||
"asOf" bigint NOT NULL,
|
||||
"notes" text NOT NULL,
|
||||
PRIMARY KEY ("requestId", "asOf"));
|
||||
COMMENT ON TABLE mpj.note IS 'Notes regarding a request'`)
|
||||
check(`request.userId Index`, indexSQL(`request`, `idx_request_userId`),
|
||||
`CREATE INDEX "idx_request_userId" ON mpj.request ("userId");
|
||||
COMMENT ON INDEX "idx_request_userId" IS 'Requests are retrieved by user'`)
|
||||
check(`journal View`, `SELECT 1 FROM pg_views WHERE schemaname='mpj' AND viewname='journal'`,
|
||||
`CREATE VIEW mpj.journal AS
|
||||
SELECT
|
||||
request."requestId",
|
||||
request."userId",
|
||||
(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",
|
||||
(SELECT "status"
|
||||
FROM mpj.history
|
||||
WHERE history."requestId" = request."requestId"
|
||||
ORDER BY "asOf" DESC
|
||||
LIMIT 1) AS "lastStatus",
|
||||
request."snoozedUntil"
|
||||
FROM mpj.request;
|
||||
COMMENT ON VIEW mpj.journal IS 'Requests with latest text'`)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package data
|
||||
|
||||
// History is a record of action taken on a prayer request, including updates to its text.
|
||||
type History struct {
|
||||
RequestID string `json:"requestId"`
|
||||
AsOf int64 `json:"asOf"`
|
||||
Status string `json:"status"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Note is a note regarding a prayer request that does not result in an update to its text.
|
||||
type Note struct {
|
||||
RequestID string `json:"requestId"`
|
||||
AsOf int64 `json:"asOf"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// Request is the identifying record for a prayer request.
|
||||
type Request struct {
|
||||
ID string `json:"requestId"`
|
||||
EnteredOn int64 `json:"enteredOn"`
|
||||
UserID string `json:"userId"`
|
||||
SnoozedUntil int64 `json:"snoozedUntil"`
|
||||
}
|
||||
|
||||
// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
||||
// properties that may be filled for history and notes.
|
||||
type JournalRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Text string `json:"text"`
|
||||
AsOf int64 `json:"asOf"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
SnoozedUntil int64 `json:"snoozedUntil"`
|
||||
History []History `json:"history,omitempty"`
|
||||
Notes []Note `json:"notes,omitempty"`
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danieljsummers/myPrayerJournal/src/api/data"
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
routing "github.com/go-ozzo/ozzo-routing"
|
||||
)
|
||||
|
||||
/* Support */
|
||||
|
||||
// Set the content type, the HTTP error code, and return the error message.
|
||||
func sendError(c *routing.Context, err error) error {
|
||||
w := c.Response
|
||||
w.Header().Set("Content-Type", "application/json; encoding=UTF-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); err != nil {
|
||||
log.Print("Error creating error JSON: " + err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the content type and return the JSON to the user.
|
||||
func sendJSON(c *routing.Context, result interface{}) error {
|
||||
w := c.Response
|
||||
w.Header().Set("Content-Type", "application/json; encoding=UTF-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
return sendError(c, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send an HTTP 404 response.
|
||||
func notFound(c *routing.Context) error {
|
||||
c.Response.WriteHeader(404)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the request body as JSON.
|
||||
func parseJSON(c *routing.Context) (map[string]interface{}, error) {
|
||||
payload := make(map[string]interface{})
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil {
|
||||
log.Println("Error decoding JSON:", err)
|
||||
return payload, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// userID is a convenience function to extract the subscriber ID from the user's JWT.
|
||||
// NOTE: Do not call this from public routes; there are a lot of type assertions that won't be true if the request
|
||||
// hasn't gone through the authorization process.
|
||||
func userID(c *routing.Context) string {
|
||||
return c.Request.Context().Value("user").(*jwt.Token).Claims.(jwt.MapClaims)["sub"].(string)
|
||||
}
|
||||
|
||||
/* Handlers */
|
||||
|
||||
// GET: /api/journal/
|
||||
func journal(c *routing.Context) error {
|
||||
reqs := data.Journal(userID(c))
|
||||
if reqs == nil {
|
||||
reqs = []data.JournalRequest{}
|
||||
}
|
||||
return sendJSON(c, reqs)
|
||||
}
|
||||
|
||||
// POST: /api/request/
|
||||
func requestAdd(c *routing.Context) error {
|
||||
payload, err := parseJSON(c)
|
||||
if err != nil {
|
||||
return sendError(c, err)
|
||||
}
|
||||
result, ok := data.AddNew(userID(c), payload["requestText"].(string))
|
||||
if !ok {
|
||||
return sendError(c, errors.New("error adding request"))
|
||||
}
|
||||
return sendJSON(c, result)
|
||||
}
|
||||
|
||||
// GET: /api/request/<id>
|
||||
func requestGet(c *routing.Context) error {
|
||||
request, ok := data.ByID(userID(c), c.Param("id"))
|
||||
if !ok {
|
||||
return sendError(c, errors.New("error retrieving request"))
|
||||
}
|
||||
if request == nil {
|
||||
return notFound(c)
|
||||
}
|
||||
return sendJSON(c, request)
|
||||
}
|
||||
|
||||
// GET: /api/request/<id>/complete
|
||||
func requestGetComplete(c *routing.Context) error {
|
||||
request, ok := data.FullByID(userID(c), c.Param("id"))
|
||||
if !ok {
|
||||
return sendError(c, errors.New("error retrieving request"))
|
||||
}
|
||||
var err error
|
||||
request.Notes, err = data.NotesByID(userID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
return sendError(c, err)
|
||||
}
|
||||
return sendJSON(c, request)
|
||||
}
|
||||
|
||||
// GET: /api/request/<id>/full
|
||||
func requestGetFull(c *routing.Context) error {
|
||||
request, ok := data.FullByID(userID(c), c.Param("id"))
|
||||
if !ok {
|
||||
return sendError(c, errors.New("error retrieving request"))
|
||||
}
|
||||
return sendJSON(c, request)
|
||||
}
|
||||
|
||||
// POST: /api/request/<id>/history
|
||||
func requestAddHistory(c *routing.Context) error {
|
||||
payload, err := parseJSON(c)
|
||||
if err != nil {
|
||||
return sendError(c, err)
|
||||
}
|
||||
c.Response.WriteHeader(
|
||||
data.AddHistory(userID(c), c.Param("id"), payload["status"].(string), payload["updateText"].(string)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// POST: /api/request/<id>/note
|
||||
func requestAddNote(c *routing.Context) error {
|
||||
payload, err := parseJSON(c)
|
||||
if err != nil {
|
||||
return sendError(c, err)
|
||||
}
|
||||
c.Response.WriteHeader(data.AddNote(userID(c), c.Param("id"), payload["notes"].(string)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GET: /api/request/<id>/notes
|
||||
func requestGetNotes(c *routing.Context) error {
|
||||
notes, err := data.NotesByID(userID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return notFound(c)
|
||||
}
|
||||
return sendError(c, err)
|
||||
}
|
||||
if notes == nil {
|
||||
notes = []data.Note{}
|
||||
}
|
||||
return sendJSON(c, notes)
|
||||
}
|
||||
|
||||
// POST: /api/request/<id>/snooze
|
||||
func requestSnooze(c *routing.Context) error {
|
||||
payload, err := parseJSON(c)
|
||||
if err != nil {
|
||||
return sendError(c, err)
|
||||
}
|
||||
c.Response.WriteHeader(data.SnoozeByID(userID(c), c.Param("id"), payload["until"].(int64)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GET: /api/request/answered
|
||||
func requestsAnswered(c *routing.Context) error {
|
||||
reqs := data.Answered(userID(c))
|
||||
if reqs == nil {
|
||||
reqs = []data.JournalRequest{}
|
||||
}
|
||||
return sendJSON(c, reqs)
|
||||
}
|
||||
|
||||
// GET: /*
|
||||
func staticFiles(c *routing.Context) error {
|
||||
// serve index for known routes handled client-side by the app
|
||||
r := c.Request
|
||||
w := c.Response
|
||||
for _, prefix := range ClientPrefixes {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
http.ServeFile(w, r, "./public/index.html")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 404 here is fine; quit hacking, y'all...
|
||||
http.ServeFile(w, r, "./public"+r.URL.Path)
|
||||
return nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/auth0/go-jwt-middleware"
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-ozzo/ozzo-routing"
|
||||
"github.com/go-ozzo/ozzo-routing/fault"
|
||||
)
|
||||
|
||||
// AuthConfig contains the Auth0 configuration passed from the "auth" JSON object.
|
||||
type AuthConfig struct {
|
||||
Domain string `json:"domain"`
|
||||
ClientID string `json:"id"`
|
||||
ClientSecret string `json:"secret"`
|
||||
}
|
||||
|
||||
// JWKS is a structure into which the JSON Web Key Set is unmarshaled.
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// JWK is a structure into which a single JSON Web Key is unmarshaled.
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
X5c []string `json:"x5c"`
|
||||
}
|
||||
|
||||
// authCfg is the Auth0 configuration provided at application startup.
|
||||
var authCfg *AuthConfig
|
||||
|
||||
// jwksBytes is a cache of the JSON Web Key Set for this domain.
|
||||
var jwksBytes = make([]byte, 0)
|
||||
|
||||
// getPEMCert is a function to get the applicable certificate for a JSON Web Token.
|
||||
func getPEMCert(token *jwt.Token) (string, error) {
|
||||
cert := ""
|
||||
|
||||
if len(jwksBytes) == 0 {
|
||||
resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/jwks.json", authCfg.Domain))
|
||||
if err != nil {
|
||||
return cert, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if jwksBytes, err = ioutil.ReadAll(resp.Body); err != nil {
|
||||
return cert, err
|
||||
}
|
||||
}
|
||||
|
||||
jwks := JWKS{}
|
||||
if err := json.Unmarshal(jwksBytes, &jwks); err != nil {
|
||||
return cert, err
|
||||
}
|
||||
for k, v := range jwks.Keys[0].X5c {
|
||||
if token.Header["kid"] == jwks.Keys[k].Kid {
|
||||
cert = fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", v)
|
||||
}
|
||||
}
|
||||
if cert == "" {
|
||||
err := errors.New("unable to find appropriate key")
|
||||
return cert, err
|
||||
}
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// authZero is an instance of Auth0's JWT middlware. Since it doesn't support the http.HandlerFunc sig, it is wrapped
|
||||
// below; it's defined outside that function, though, so it does not get recreated every time.
|
||||
var authZero = jwtmiddleware.New(jwtmiddleware.Options{
|
||||
ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) {
|
||||
if checkAud := token.Claims.(jwt.MapClaims).VerifyAudience(authCfg.ClientID, false); !checkAud {
|
||||
return token, errors.New("invalid audience")
|
||||
}
|
||||
iss := fmt.Sprintf("https://%s/", authCfg.Domain)
|
||||
if checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(iss, false); !checkIss {
|
||||
return token, errors.New("invalid issuer")
|
||||
}
|
||||
|
||||
cert, err := getPEMCert(token)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
result, _ := jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
|
||||
return result, nil
|
||||
},
|
||||
SigningMethod: jwt.SigningMethodRS256,
|
||||
})
|
||||
|
||||
// authMiddleware is a wrapper for the Auth0 middleware above with a signature ozzo-routing recognizes.
|
||||
func authMiddleware(c *routing.Context) error {
|
||||
return authZero.CheckJWT(c.Response, c.Request)
|
||||
}
|
||||
|
||||
// NewRouter returns a configured router to handle all incoming requests.
|
||||
func NewRouter(cfg *AuthConfig) *routing.Router {
|
||||
authCfg = cfg
|
||||
router := routing.New()
|
||||
router.Use(fault.Recovery(log.Printf))
|
||||
for _, route := range routes {
|
||||
if route.IsPublic {
|
||||
router.To(route.Method, route.Pattern, route.Func)
|
||||
} else {
|
||||
router.To(route.Method, route.Pattern, authMiddleware, route.Func)
|
||||
}
|
||||
}
|
||||
return router
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Package routes contains endpoint handlers for the myPrayerJournal API.
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
routing "github.com/go-ozzo/ozzo-routing"
|
||||
)
|
||||
|
||||
// Route is a route served in the application.
|
||||
type Route struct {
|
||||
Name string
|
||||
Method string
|
||||
Pattern string
|
||||
Func routing.Handler
|
||||
IsPublic bool
|
||||
}
|
||||
|
||||
// Routes is the collection of all routes served in the application.
|
||||
type Routes []Route
|
||||
|
||||
// routes is the actual list of routes for the application.
|
||||
var routes = Routes{
|
||||
Route{
|
||||
"Journal",
|
||||
http.MethodGet,
|
||||
"/api/journal/",
|
||||
journal,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"AddNewRequest",
|
||||
http.MethodPost,
|
||||
"/api/request/",
|
||||
requestAdd,
|
||||
false,
|
||||
},
|
||||
// Must be above GetRequestByID
|
||||
Route{
|
||||
"GetAnsweredRequests",
|
||||
http.MethodGet,
|
||||
"/api/request/answered",
|
||||
requestsAnswered,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"GetRequestByID",
|
||||
http.MethodGet,
|
||||
"/api/request/<id>",
|
||||
requestGet,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"GetCompleteRequestByID",
|
||||
http.MethodGet,
|
||||
"/api/request/<id>/complete",
|
||||
requestGetComplete,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"GetFullRequestByID",
|
||||
http.MethodGet,
|
||||
"/api/request/<id>/full",
|
||||
requestGetFull,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"AddNewHistoryEntry",
|
||||
http.MethodPost,
|
||||
"/api/request/<id>/history",
|
||||
requestAddHistory,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"AddNewNote",
|
||||
http.MethodPost,
|
||||
"/api/request/<id>/note",
|
||||
requestAddNote,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"GetNotesForRequest",
|
||||
http.MethodGet,
|
||||
"/api/request/<id>/notes",
|
||||
requestGetNotes,
|
||||
false,
|
||||
},
|
||||
Route{
|
||||
"SnoozeRequest",
|
||||
http.MethodPost,
|
||||
"/api/request/<id>/snooze",
|
||||
requestSnooze,
|
||||
false,
|
||||
},
|
||||
// keep this route last
|
||||
Route{
|
||||
"StaticFiles",
|
||||
http.MethodGet,
|
||||
"/*",
|
||||
staticFiles,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
// ClientPrefixes is a list of known route prefixes handled by the Vue app.
|
||||
var ClientPrefixes = []string{"/answered", "/journal", "/user"}
|
||||
@@ -4,8 +4,8 @@ var path = require('path')
|
||||
module.exports = {
|
||||
build: {
|
||||
env: require('./prod.env'),
|
||||
index: path.resolve(__dirname, '../../public/index.html'),
|
||||
assetsRoot: path.resolve(__dirname, '../../public'),
|
||||
index: path.resolve(__dirname, '../../api/MyPrayerJournal.Api/wwwroot/index.html'),
|
||||
assetsRoot: path.resolve(__dirname, '../../api/MyPrayerJournal.Api/wwwroot'),
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
productionSourceMap: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-prayer-journal",
|
||||
"version": "0.9.6",
|
||||
"version": "0.9.7",
|
||||
"description": "myPrayerJournal - Front End",
|
||||
"author": "Daniel J. Summers <daniel@bitbadger.solutions>",
|
||||
"private": true,
|
||||
@@ -12,8 +12,8 @@
|
||||
"e2e": "node test/e2e/runner.js",
|
||||
"test": "npm run unit && npm run e2e",
|
||||
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
|
||||
"apistart": "cd .. && go build -o mpj-api.exe && mpj-api.exe",
|
||||
"vue": "node build/build.js prod && cd .. && go build -o mpj-api.exe && mpj-api.exe"
|
||||
"apistart": "cd ../api/MyPrayerJournal.Api && dotnet run",
|
||||
"vue": "node build/build.js prod && cd ../api/MyPrayerJournal.Api && dotnet run"
|
||||
},
|
||||
"dependencies": {
|
||||
"auth0-js": "^9.3.3",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
em: small.
|
||||
#[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] •
|
||||
#[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] •
|
||||
#[a(href='https://github.com/danieljsummers/myprayerjournal') Developed] and hosted by
|
||||
#[a(href='https://github.com/bit-badger/myprayerjournal') Developed] and hosted by
|
||||
#[a(href='https://bitbadger.solutions') Bit Badger Solutions]
|
||||
</template>
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ export default {
|
||||
* Add a new prayer request
|
||||
* @param {string} requestText The text of the request to be added
|
||||
*/
|
||||
addRequest: requestText => http.post('request/', { requestText }),
|
||||
addRequest: requestText => http.post('request', { requestText }),
|
||||
|
||||
/**
|
||||
* Get all answered requests, along with the text they had when it was answered
|
||||
*/
|
||||
getAnsweredRequests: () => http.get('request/answered'),
|
||||
getAnsweredRequests: () => http.get('requests/answered'),
|
||||
|
||||
/**
|
||||
* Get a prayer request (full; includes all history)
|
||||
@@ -64,7 +64,14 @@ export default {
|
||||
/**
|
||||
* Get all prayer requests and their most recent updates
|
||||
*/
|
||||
journal: () => http.get('journal/'),
|
||||
journal: () => http.get('journal'),
|
||||
|
||||
/**
|
||||
* Snooze a request until the given time
|
||||
* @param requestId {string} The ID of the prayer request to be snoozed
|
||||
* @param until {number} The ticks until which the request should be snoozed
|
||||
*/
|
||||
snoozeRequest: (requestId, until) => http.post(`request/${requestId}/snooze`, { until }),
|
||||
|
||||
/**
|
||||
* Update a prayer request
|
||||
|
||||
@@ -10,6 +10,7 @@ article
|
||||
b-table(small hover :fields='fields' :items='log')
|
||||
template(slot='action' scope='data').
|
||||
{{ data.item.status }} on #[span.text-nowrap {{ formatDate(data.item.asOf) }}]
|
||||
template(slot='text' scope='data' v-if='data.item.text') {{ data.item.text.fields[0] }}
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -44,12 +45,12 @@ export default {
|
||||
},
|
||||
lastText () {
|
||||
return this.request.history
|
||||
.filter(hist => hist.text > '')
|
||||
.sort(asOfDesc)[0].text
|
||||
.filter(hist => hist.text)
|
||||
.sort(asOfDesc)[0].text.fields[0]
|
||||
},
|
||||
log () {
|
||||
return (this.request.notes || [])
|
||||
.map(note => ({ asOf: note.asOf, text: note.notes, status: 'Notes' }))
|
||||
.map(note => ({ asOf: note.asOf, text: { case: 'Some', fields: [ note.notes ] }, status: 'Notes' }))
|
||||
.concat(this.request.history)
|
||||
.sort(asOfDesc)
|
||||
.slice(1)
|
||||
|
||||
@@ -18,6 +18,8 @@ article
|
||||
notes-edit(:events='eventBus'
|
||||
:toast='toast')
|
||||
full-request(:events='eventBus')
|
||||
snooze-request(:events='eventBus'
|
||||
:toast='toast')
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -31,6 +33,7 @@ import FullRequest from './request/FullRequest'
|
||||
import NewRequest from './request/NewRequest'
|
||||
import NotesEdit from './request/NotesEdit'
|
||||
import RequestCard from './request/RequestCard'
|
||||
import SnoozeRequest from './request/SnoozeRequest'
|
||||
|
||||
import actions from '@/store/action-types'
|
||||
|
||||
@@ -41,7 +44,8 @@ export default {
|
||||
FullRequest,
|
||||
NewRequest,
|
||||
NotesEdit,
|
||||
RequestCard
|
||||
RequestCard,
|
||||
SnoozeRequest
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -12,11 +12,13 @@ b-navbar(toggleable='sm'
|
||||
b-navbar-nav
|
||||
b-nav-item(v-if='isAuthenticated'
|
||||
to='/journal') Journal
|
||||
b-nav-item(v-if='hasSnoozed'
|
||||
to='/snoozed') Snoozed
|
||||
b-nav-item(v-if='isAuthenticated'
|
||||
to='/answered') Answered
|
||||
b-nav-item(v-if='isAuthenticated'): a(@click.stop='logOff()') Log Off
|
||||
b-nav-item(v-if='!isAuthenticated'): a(@click.stop='logOn()') Log On
|
||||
b-nav-item(href='https://danieljsummers.github.io/myPrayerJournal/'
|
||||
b-nav-item(href='https://bit-badger.github.io/myPrayerJournal/'
|
||||
target='_blank'
|
||||
@click.stop='') Docs
|
||||
</template>
|
||||
@@ -35,7 +37,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState([ 'isAuthenticated' ])
|
||||
hasSnoozed () {
|
||||
return this.isAuthenticated &&
|
||||
Array.isArray(this.journal) &&
|
||||
this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0
|
||||
},
|
||||
...mapState([ 'journal', 'isAuthenticated' ])
|
||||
},
|
||||
methods: {
|
||||
logOn () {
|
||||
|
||||
76
src/app/src/components/Snoozed.vue
Normal file
76
src/app/src/components/Snoozed.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
page-title(title='Snoozed Requests')
|
||||
p(v-if='!loaded') Loading journal...
|
||||
div(v-if='loaded').mpj-snoozed-list
|
||||
p.text-center(v-if='requests.length === 0'): em.
|
||||
No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
|
||||
p.mpj-snoozed-text(v-for='req in requests' :key='req.requestId')
|
||||
| {{ req.text }}
|
||||
br
|
||||
br
|
||||
b-btn(@click='cancelSnooze(req.requestId)'
|
||||
size='sm'
|
||||
variant='outline-secondary')
|
||||
icon(name='times')
|
||||
= ' Cancel Snooze'
|
||||
small.text-muted: em.
|
||||
Snooze expires #[date-from-now(:value='req.snoozedUntil')]
|
||||
</template>
|
||||
|
||||
<script>
|
||||
'use static'
|
||||
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'answered',
|
||||
data () {
|
||||
return {
|
||||
requests: [],
|
||||
loaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
},
|
||||
...mapState(['journal', 'isLoadingJournal'])
|
||||
},
|
||||
methods: {
|
||||
async ensureJournal () {
|
||||
if (!Array.isArray(this.journal)) {
|
||||
this.loaded = false
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
}
|
||||
this.requests = this.journal
|
||||
.filter(req => req.snoozedUntil > Date.now())
|
||||
.sort((a, b) => a.snoozedUntil - b.snoozedUntil)
|
||||
this.loaded = true
|
||||
},
|
||||
async cancelSnooze (requestId) {
|
||||
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
requestId: requestId,
|
||||
until: 0
|
||||
})
|
||||
this.toast.showToast('Request un-snoozed', { theme: 'success' })
|
||||
this.ensureJournal()
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
await this.ensureJournal()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mpj-snoozed-list p {
|
||||
border-top: solid 1px lightgray;
|
||||
}
|
||||
.mpj-snoozed-list p:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
b-list-group-item
|
||||
| {{ history.status }}
|
||||
|
|
||||
small.text-muted {{ asOf }}
|
||||
div(v-if='hasText').mpj-request-text {{ history.text }}
|
||||
small.text-muted(:title='actualDate') {{ asOf }}
|
||||
div(v-if='history.text').mpj-request-text {{ history.text.fields[0] }}
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -20,8 +20,8 @@ export default {
|
||||
asOf () {
|
||||
return moment(this.history.asOf).fromNow()
|
||||
},
|
||||
hasText () {
|
||||
return this.history.text.length > 0
|
||||
actualDate () {
|
||||
return moment(this.history.asOf).format('LLLL')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
this.events.$emit('notes', this.request)
|
||||
},
|
||||
snooze () {
|
||||
// Nothing yet
|
||||
this.events.$emit('snooze', this.request.requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
src/app/src/components/request/SnoozeRequest.vue
Normal file
72
src/app/src/components/request/SnoozeRequest.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template lang="pug">
|
||||
b-modal(v-model='snoozeVisible'
|
||||
header-bg-variant='mpj'
|
||||
header-text-variant='light'
|
||||
size='lg'
|
||||
title='Snooze Prayer Request'
|
||||
@edit='openDialog()')
|
||||
b-form
|
||||
b-form-group(label='Until'
|
||||
label-for='until')
|
||||
b-input#until(type='date'
|
||||
v-model='form.snoozedUntil'
|
||||
autofocus)
|
||||
div.w-100.text-right(slot='modal-footer')
|
||||
b-btn(variant='primary'
|
||||
:disabled='!isValid'
|
||||
@click='snoozeRequest()') Snooze
|
||||
|
|
||||
b-btn(variant='outline-secondary'
|
||||
@click='closeDialog()') Cancel
|
||||
</template>
|
||||
|
||||
<script>
|
||||
'use strict'
|
||||
|
||||
import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'snooze-request',
|
||||
props: {
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
snoozeVisible: false,
|
||||
form: {
|
||||
requestId: '',
|
||||
snoozedUntil: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.events.$on('snooze', this.openDialog)
|
||||
},
|
||||
computed: {
|
||||
isValid () {
|
||||
return !isNaN(Date.parse(this.form.snoozedUntil))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog () {
|
||||
this.form.requestId = ''
|
||||
this.form.snoozedUntil = ''
|
||||
this.snoozeVisible = false
|
||||
},
|
||||
openDialog (requestId) {
|
||||
this.form.requestId = requestId
|
||||
this.snoozeVisible = true
|
||||
},
|
||||
async snoozeRequest () {
|
||||
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
requestId: this.form.requestId,
|
||||
until: Date.parse(this.form.snoozedUntil)
|
||||
})
|
||||
this.toast.showToast(`Request snoozed until ${this.form.snoozedUntil}`, { theme: 'success' })
|
||||
this.closeDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -17,6 +17,7 @@ import 'vue-awesome/icons/file-text-o'
|
||||
import 'vue-awesome/icons/pencil'
|
||||
import 'vue-awesome/icons/plus'
|
||||
import 'vue-awesome/icons/search'
|
||||
import 'vue-awesome/icons/times'
|
||||
|
||||
import App from './App'
|
||||
import router from './router'
|
||||
|
||||
@@ -7,6 +7,7 @@ import Home from '@/components/Home'
|
||||
import Journal from '@/components/Journal'
|
||||
import LogOn from '@/components/user/LogOn'
|
||||
import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
|
||||
import Snoozed from '@/components/Snoozed'
|
||||
import TermsOfService from '@/components/legal/TermsOfService'
|
||||
|
||||
Vue.use(Router)
|
||||
@@ -45,6 +46,11 @@ export default new Router({
|
||||
name: 'TermsOfService',
|
||||
component: TermsOfService
|
||||
},
|
||||
{
|
||||
path: '/snoozed',
|
||||
name: 'Snoozed',
|
||||
component: Snoozed
|
||||
},
|
||||
{
|
||||
path: '/user/log-on',
|
||||
name: 'LogOn',
|
||||
|
||||
@@ -6,5 +6,7 @@ export default {
|
||||
/** Action to load the user's prayer journal */
|
||||
LOAD_JOURNAL: 'load-journal',
|
||||
/** Action to update a request */
|
||||
UPDATE_REQUEST: 'update-request'
|
||||
UPDATE_REQUEST: 'update-request',
|
||||
/** Action to snooze a request */
|
||||
SNOOZE_REQUEST: 'snooze-request'
|
||||
}
|
||||
|
||||
@@ -109,6 +109,18 @@ export default new Vuex.Store({
|
||||
logError(err)
|
||||
progress.fail()
|
||||
}
|
||||
},
|
||||
async [actions.SNOOZE_REQUEST] ({ commit }, { progress, requestId, until }) {
|
||||
progress.start()
|
||||
try {
|
||||
await api.snoozeRequest(requestId, until)
|
||||
const request = await api.getRequest(requestId)
|
||||
commit(mutations.REQUEST_UPDATED, request.data)
|
||||
progress.finish()
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
progress.fail()
|
||||
}
|
||||
}
|
||||
},
|
||||
getters: {},
|
||||
|
||||
Reference in New Issue
Block a user