This commit is contained in:
Daniel J. Summers
2018-08-06 21:30:45 -05:00
29 changed files with 973 additions and 870 deletions

View 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
}

View 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
}

View 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>

View 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

View 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"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Kestrel": {
"EndPoints": {
"Http": {
"Url": "http://localhost:3000"
}
}
}
}

View 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

View File

@@ -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(&note.AsOf, &note.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'`)
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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,

View File

@@ -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",

View File

@@ -12,7 +12,7 @@
em: small.
#[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] &bull;
#[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] &bull;
#[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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 () {

View 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.
&nbsp; 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>

View File

@@ -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')
}
}
}

View File

@@ -53,7 +53,7 @@ export default {
this.events.$emit('notes', this.request)
},
snooze () {
// Nothing yet
this.events.$emit('snooze', this.request.requestId)
}
}
}

View 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
| &nbsp; &nbsp;
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>

View File

@@ -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'

View File

@@ -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',

View File

@@ -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'
}

View File

@@ -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: {},