First cut
A wild swing at translating the API; committing at this point just so I don't lose work
This commit is contained in:
parent
d1fd5f68e7
commit
7622163707
286
src/api/MyPrayerJournal.Api/Data.fs
Normal file
286
src/api/MyPrayerJournal.Api/Data.fs
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
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
|
||||||
|
/// The request to which this history entry applies
|
||||||
|
request : Request
|
||||||
|
}
|
||||||
|
with
|
||||||
|
/// An empty history entry
|
||||||
|
static member empty =
|
||||||
|
{ requestId = ""
|
||||||
|
asOf = 0L
|
||||||
|
status = ""
|
||||||
|
text = None
|
||||||
|
request = Request.empty
|
||||||
|
}
|
||||||
|
|
||||||
|
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.HasOne(fun e -> e.request)
|
||||||
|
.WithMany(fun r -> r.history :> IEnumerable<History>)
|
||||||
|
.HasForeignKey(fun e -> e.requestId :> obj)
|
||||||
|
|> ignore)
|
||||||
|
|> ignore
|
||||||
|
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
|
||||||
|
/// The request to which this note applies
|
||||||
|
request : Request
|
||||||
|
}
|
||||||
|
with
|
||||||
|
/// An empty note
|
||||||
|
static member empty =
|
||||||
|
{ requestId = ""
|
||||||
|
asOf = 0L
|
||||||
|
notes = ""
|
||||||
|
request = Request.empty
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
m.HasOne(fun e -> e.request)
|
||||||
|
.WithMany(fun r -> r.notes :> IEnumerable<Note>)
|
||||||
|
.HasForeignKey(fun e -> e.requestId :> obj)
|
||||||
|
|> 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)
|
||||||
|
|> 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>) as self =
|
||||||
|
inherit DbContext (opts)
|
||||||
|
|
||||||
|
/// Register a disconnected entity with the context, having the given state
|
||||||
|
let registerAs state (e : 'TEntity when 'TEntity : not struct) =
|
||||||
|
self.Entry<'TEntity>(e).State <- state
|
||||||
|
|
||||||
|
[<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)
|
||||||
|
|
||||||
|
/// Add an entity instance to the context
|
||||||
|
member __.AddEntry e =
|
||||||
|
registerAs EntityState.Added e
|
||||||
|
|
||||||
|
/// Update the entity instance's values
|
||||||
|
member __.UpdateEntry e =
|
||||||
|
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
|
||||||
|
}
|
244
src/api/MyPrayerJournal.Api/Handlers.fs
Normal file
244
src/api/MyPrayerJournal.Api/Handlers.fs
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
/// HTTP handlers for the myPrayerJournal API
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module MyPrayerJournal.Handlers
|
||||||
|
|
||||||
|
open Giraffe
|
||||||
|
|
||||||
|
/// 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 ->
|
||||||
|
let vueApp () = htmlFile "/index.html" next ctx
|
||||||
|
match true with
|
||||||
|
| _ when ctx.Request.Path.Value.StartsWith "/answered" -> vueApp ()
|
||||||
|
| _ when ctx.Request.Path.Value.StartsWith "/journal" -> vueApp ()
|
||||||
|
| _ when ctx.Request.Path.Value.StartsWith "/user" -> vueApp ()
|
||||||
|
| _ -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx
|
||||||
|
|
||||||
|
|
||||||
|
/// Handler helpers
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private Helpers =
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// 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 = "sub")
|
||||||
|
|
||||||
|
/// Return a 201 CREATED response
|
||||||
|
let created next ctx =
|
||||||
|
setStatusCode 201 next ctx
|
||||||
|
|
||||||
|
/// The "now" time in JavaScript
|
||||||
|
let jsNow () =
|
||||||
|
DateTime.Now.Subtract(DateTime (1970, 1, 1)).TotalSeconds |> int64 |> (*) 1000L
|
||||||
|
|
||||||
|
|
||||||
|
/// 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 =
|
||||||
|
fun next ctx ->
|
||||||
|
match user ctx with
|
||||||
|
| Some u -> json ((db ctx).JournalByUserId u.Value) next ctx
|
||||||
|
| None -> notFound next ctx
|
||||||
|
|
||||||
|
|
||||||
|
/// /api/request URLs
|
||||||
|
module Request =
|
||||||
|
|
||||||
|
open NCuid
|
||||||
|
|
||||||
|
/// POST /api/request
|
||||||
|
let add : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let! r = ctx.BindJsonAsync<Models.Request> ()
|
||||||
|
let db = db ctx
|
||||||
|
let reqId = Cuid.Generate ()
|
||||||
|
let now = jsNow ()
|
||||||
|
{ Request.empty with
|
||||||
|
requestId = reqId
|
||||||
|
userId = u.Value
|
||||||
|
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 u.Value
|
||||||
|
match req with
|
||||||
|
| Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/request/[req-id]/history
|
||||||
|
let addHistory reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let db = db ctx
|
||||||
|
let! req = db.TryRequestById reqId u.Value
|
||||||
|
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! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/request/[req-id]/note
|
||||||
|
let addNote reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let db = db ctx
|
||||||
|
let! req = db.TryRequestById reqId u.Value
|
||||||
|
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! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/requests/answered
|
||||||
|
let answered : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
match user ctx with
|
||||||
|
| Some u -> json ((db ctx).AnsweredRequests u.Value) next ctx
|
||||||
|
| None -> notFound next ctx
|
||||||
|
|
||||||
|
/// GET /api/request/[req-id]
|
||||||
|
let get reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let! req = (db ctx).TryRequestById reqId u.Value
|
||||||
|
match req with
|
||||||
|
| Some r -> return! json r next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/request/[req-id]/complete
|
||||||
|
let getComplete reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let! req = (db ctx).TryCompleteRequestById reqId u.Value
|
||||||
|
match req with
|
||||||
|
| Some r -> return! json r next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/request/[req-id]/full
|
||||||
|
let getFull reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let! req = (db ctx).TryFullRequestById reqId u.Value
|
||||||
|
match req with
|
||||||
|
| Some r -> return! json r next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/request/[req-id]/notes
|
||||||
|
let getNotes reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let! notes = (db ctx).NotesById reqId u.Value
|
||||||
|
return! json notes next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/request/[req-id]/snooze
|
||||||
|
let snooze reqId : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
task {
|
||||||
|
match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
let db = db ctx
|
||||||
|
let! req = db.TryRequestById reqId u.Value
|
||||||
|
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! notFound next ctx
|
||||||
|
| None -> return! notFound next ctx
|
||||||
|
}
|
25
src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj
Normal file
25
src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Update="FSharp.Core" Version="4.5.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
85
src/api/MyPrayerJournal.Api/Program.fs
Normal file
85
src/api/MyPrayerJournal.Api/Program.fs
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
namespace MyPrayerJournal.Api
|
||||||
|
|
||||||
|
open System
|
||||||
|
open Microsoft.AspNetCore
|
||||||
|
open Microsoft.AspNetCore.Builder
|
||||||
|
open Microsoft.AspNetCore.Hosting
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
|
/// Configuration functions for the application
|
||||||
|
module Configure =
|
||||||
|
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open Giraffe
|
||||||
|
open Giraffe.TokenRouter
|
||||||
|
open MyPrayerJournal
|
||||||
|
|
||||||
|
/// Configure dependency injection
|
||||||
|
let services (sc : IServiceCollection) =
|
||||||
|
sc.AddAuthentication()
|
||||||
|
.AddJwtBearer ("Auth0",
|
||||||
|
fun opt ->
|
||||||
|
opt.Audience <- "")
|
||||||
|
|> ignore
|
||||||
|
()
|
||||||
|
|
||||||
|
/// Response that will load the Vue application to handle the given URL
|
||||||
|
let vueApp = fun next ctx -> htmlFile "/index.html" next ctx
|
||||||
|
|
||||||
|
/// Routes for the available URLs within myPrayerJournal
|
||||||
|
let webApp =
|
||||||
|
router Handlers.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> ()
|
||||||
|
let log = app.ApplicationServices.GetService<ILoggerFactory> ()
|
||||||
|
match env.IsDevelopment () with
|
||||||
|
| true ->
|
||||||
|
app.UseDeveloperExceptionPage () |> ignore
|
||||||
|
| false ->
|
||||||
|
()
|
||||||
|
|
||||||
|
app.UseAuthentication()
|
||||||
|
.UseStaticFiles()
|
||||||
|
.UseGiraffe webApp
|
||||||
|
|> ignore
|
||||||
|
|
||||||
|
|
||||||
|
module Program =
|
||||||
|
|
||||||
|
let exitCode = 0
|
||||||
|
|
||||||
|
let CreateWebHostBuilder args =
|
||||||
|
WebHost
|
||||||
|
.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureServices(Configure.services)
|
||||||
|
.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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"}
|
|
Loading…
Reference in New Issue
Block a user