Merge branch 'go-backend'
This commit is contained in:
commit
6424cde1b6
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -254,7 +254,8 @@ paket-files/
|
||||||
|
|
||||||
# Compiled files / application
|
# Compiled files / application
|
||||||
src/api/build
|
src/api/build
|
||||||
src/api/public/index.html
|
src/public/index.html
|
||||||
src/api/public/static
|
src/public/static
|
||||||
src/api/appsettings.json
|
src/config.json
|
||||||
/build
|
/build
|
||||||
|
src/*.exe
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
const chalk = require('chalk')
|
|
||||||
|
|
||||||
const { env } = require('./appsettings.json') // process.env.NODE_ENV || 'dev'
|
|
||||||
|
|
||||||
if ('dev' === env) require('babel-register')
|
|
||||||
const src = (env === 'dev') ? './src' : './build'
|
|
||||||
|
|
||||||
const app = require(`${src}/index`).default
|
|
||||||
const db = require(`${src}/db`).default
|
|
||||||
|
|
||||||
const fullEnv = ('dev' === env) ? 'Development' : 'Production'
|
|
||||||
|
|
||||||
const { port } = require('./appsettings.json')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a start-up message for the app
|
|
||||||
* @param {string} status The status to display
|
|
||||||
*/
|
|
||||||
const startupMsg = (status) => {
|
|
||||||
console.log(chalk`{reset myPrayerJournal ${status} | Port: {bold ${port}} | Mode: {bold ${fullEnv}}}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the database exists before starting up
|
|
||||||
db.verify()
|
|
||||||
.then(() => app.listen(port, () => startupMsg('ready')))
|
|
||||||
.catch(err => {
|
|
||||||
console.log(chalk`\n{reset {bgRed.white.bold || Error connecting to PostgreSQL }}`)
|
|
||||||
for (let key of Object.keys(err)) {
|
|
||||||
console.log(chalk`${key}: {reset {bold ${err[key]}}}`)
|
|
||||||
}
|
|
||||||
console.log('')
|
|
||||||
startupMsg('failed')
|
|
||||||
})
|
|
366
src/api/data/data.go
Normal file
366
src/api/data/data.go
Normal file
|
@ -0,0 +1,366 @@
|
||||||
|
// 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"
|
||||||
|
FROM mpj.journal`
|
||||||
|
journalSQL = `
|
||||||
|
SELECT "requestId", "text", "asOf", "lastStatus"
|
||||||
|
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"
|
||||||
|
FROM mpj.request
|
||||||
|
WHERE "requestId" = $1
|
||||||
|
AND "userId" = $2`, reqID, userID).Scan(
|
||||||
|
&req.ID, &req.EnteredOn,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
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") VALUES ($1, $2, $3)`,
|
||||||
|
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`}, 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,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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)
|
||||||
|
}
|
||||||
|
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(`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"
|
||||||
|
FROM mpj.request;
|
||||||
|
COMMENT ON VIEW mpj.journal IS 'Requests with latest text'`)
|
||||||
|
}
|
34
src/api/data/entities.go
Normal file
34
src/api/data/entities.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
History []History `json:"history,omitempty"`
|
||||||
|
Notes []Note `json:"notes,omitempty"`
|
||||||
|
}
|
|
@ -1,12 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import fs from 'fs'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read and parse a JSON file
|
|
||||||
* @param {string} path The path to the file
|
|
||||||
* @param {string} encoding The encoding of the file (defaults to UTF-8)
|
|
||||||
* @return {*} The parsed contents of the file
|
|
||||||
*/
|
|
||||||
export default (path, encoding = 'utf-8') =>
|
|
||||||
JSON.parse(fs.readFileSync(path, encoding))
|
|
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"name": "my-prayer-journal-api",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.9.3",
|
|
||||||
"description": "Server API for myPrayerJournal",
|
|
||||||
"main": "index.js",
|
|
||||||
"author": "Daniel J. Summers <daniel@bitbadger.solutions>",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "^2.1.0",
|
|
||||||
"cuid": "^1.3.8",
|
|
||||||
"jwks-rsa-koa": "^1.1.3",
|
|
||||||
"koa": "^2.3.0",
|
|
||||||
"koa-bodyparser": "^4.2.0",
|
|
||||||
"koa-jwt": "^3.2.2",
|
|
||||||
"koa-router": "^7.2.1",
|
|
||||||
"koa-send": "^4.1.0",
|
|
||||||
"koa-static": "^4.0.1",
|
|
||||||
"pg": "^7.3.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"start": "node app.js",
|
|
||||||
"build": "babel src -d build",
|
|
||||||
"vue": "cd ../app && node build/build.js prod && cd ../api && node app.js"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"babel": "^6.23.0",
|
|
||||||
"babel-cli": "^6.26.0",
|
|
||||||
"babel-preset-env": "^1.6.0",
|
|
||||||
"babel-register": "^6.26.0",
|
|
||||||
"koa-morgan": "^1.0.1"
|
|
||||||
},
|
|
||||||
"babel": {
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"node": "current"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
182
src/api/routes/handlers.go
Normal file
182
src/api/routes/handlers.go
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
123
src/api/routes/router.go
Normal file
123
src/api/routes/router.go
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
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/access"
|
||||||
|
"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(
|
||||||
|
access.Logger(log.Printf), // TODO: remove before go-live
|
||||||
|
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
|
||||||
|
}
|
99
src/api/routes/routes.go
Normal file
99
src/api/routes/routes.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
// 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"}
|
|
@ -1,108 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { Pool } from 'pg'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SQL to check the existence of a table in the mpj schema
|
|
||||||
* @param {string} table The name of the table whose existence should be checked
|
|
||||||
*/
|
|
||||||
const tableSql = table => `SELECT 1 FROM pg_tables WHERE schemaname='mpj' AND tablename='${table}'`
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SQL to determine if an index exists
|
|
||||||
* @param {string} table The name of the table which the given index indexes
|
|
||||||
* @param {string} index The name of the index
|
|
||||||
*/
|
|
||||||
const indexSql = (table, index) =>
|
|
||||||
`SELECT 1 FROM pg_indexes WHERE schemaname='mpj' AND tablename='${table}' AND indexname='${index}'`
|
|
||||||
|
|
||||||
const ddl = [
|
|
||||||
{
|
|
||||||
name: 'myPrayerJournal Schema',
|
|
||||||
check: `SELECT 1 FROM pg_namespace WHERE nspname='mpj'`,
|
|
||||||
fix: `
|
|
||||||
CREATE SCHEMA mpj;
|
|
||||||
COMMENT ON SCHEMA mpj IS 'myPrayerJournal data'`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'request Table',
|
|
||||||
check: tableSql('request'),
|
|
||||||
fix: `
|
|
||||||
CREATE TABLE mpj.request (
|
|
||||||
"requestId" varchar(25) PRIMARY KEY,
|
|
||||||
"enteredOn" bigint NOT NULL,
|
|
||||||
"userId" varchar(100) NOT NULL);
|
|
||||||
COMMENT ON TABLE mpj.request IS 'Requests'`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'history Table',
|
|
||||||
check: tableSql('history'),
|
|
||||||
fix: `
|
|
||||||
CREATE TABLE mpj.history (
|
|
||||||
"requestId" varchar(25) NOT NULL REFERENCES mpj.request,
|
|
||||||
"asOf" bigint NOT NULL,
|
|
||||||
"status" varchar(25),
|
|
||||||
"text" text,
|
|
||||||
PRIMARY KEY ("requestId", "asOf"));
|
|
||||||
COMMENT ON TABLE mpj.history IS 'Request update history'`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'note Table',
|
|
||||||
check: tableSql('note'),
|
|
||||||
fix: `
|
|
||||||
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'`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'request.userId Index',
|
|
||||||
check: indexSql('request', 'idx_request_userId'),
|
|
||||||
fix: `
|
|
||||||
CREATE INDEX "idx_request_userId" ON mpj.request ("userId");
|
|
||||||
COMMENT ON INDEX "idx_request_userId" IS 'Requests are retrieved by user'`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'journal View',
|
|
||||||
check: `SELECT 1 FROM pg_views WHERE schemaname='mpj' AND viewname='journal'`,
|
|
||||||
fix: `
|
|
||||||
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"
|
|
||||||
FROM mpj.request;
|
|
||||||
COMMENT ON VIEW mpj.journal IS 'Requests with latest text'`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function (query) {
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Ensure that the database schema, tables, and indexes exist
|
|
||||||
*/
|
|
||||||
ensureDatabase: async () => {
|
|
||||||
for (let item of ddl) {
|
|
||||||
const result = await query(item.check, [])
|
|
||||||
if (1 > result.rowCount) await query(item.fix, [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { Pool, types } from 'pg'
|
|
||||||
|
|
||||||
import appConfig from '../../appsettings.json'
|
|
||||||
import ddl from './ddl'
|
|
||||||
import request from './request'
|
|
||||||
|
|
||||||
/** Pooled PostgreSQL instance */
|
|
||||||
const pool = new Pool(appConfig.pgPool)
|
|
||||||
|
|
||||||
// Return "bigint" (int8) instances as number instead of strings
|
|
||||||
// ref: https://github.com/brianc/node-pg-types
|
|
||||||
types.setTypeParser(20, val => parseInt(val))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a SQL query
|
|
||||||
* @param {string} text The SQL command
|
|
||||||
* @param {*[]} params The parameters for the query
|
|
||||||
*/
|
|
||||||
const query = (text, params) => pool.query(text, params)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
query: query,
|
|
||||||
request: request(pool),
|
|
||||||
verify: ddl(query).ensureDatabase
|
|
||||||
}
|
|
|
@ -1,188 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { Pool } from 'pg'
|
|
||||||
import cuid from 'cuid'
|
|
||||||
|
|
||||||
const currentRequestSql = `
|
|
||||||
SELECT "requestId", "text", "asOf", "lastStatus"
|
|
||||||
FROM mpj.journal`
|
|
||||||
|
|
||||||
const journalSql = `${currentRequestSql}
|
|
||||||
WHERE "userId" = $1
|
|
||||||
AND "lastStatus" <> 'Answered'`
|
|
||||||
|
|
||||||
const requestNotFound = {
|
|
||||||
requestId: '',
|
|
||||||
text: 'Not Found',
|
|
||||||
asOf: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pool) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve basic information about a single request
|
|
||||||
* @param {string} requestId The Id of the request to retrieve
|
|
||||||
* @param {string} userId The Id of the user to whom the request belongs
|
|
||||||
*/
|
|
||||||
let retrieveRequest = (requestId, userId) =>
|
|
||||||
pool.query(`
|
|
||||||
SELECT "requestId", "enteredOn"
|
|
||||||
FROM mpj.request
|
|
||||||
WHERE "requestId" = $1
|
|
||||||
AND "userId" = $2`,
|
|
||||||
[ requestId, userId ])
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Add a history entry for this request
|
|
||||||
* @param {string} userId The Id of the user to whom this request belongs
|
|
||||||
* @param {string} requestId The Id of the request to which the update applies
|
|
||||||
* @param {string} status The status for this history entry
|
|
||||||
* @param {string} updateText The updated text for the request (pass blank if no update)
|
|
||||||
* @return {number} 404 if the request is not found or does not belong to the given user, 204 if successful
|
|
||||||
*/
|
|
||||||
addHistory: async (userId, requestId, status, updateText) => {
|
|
||||||
const req = retrieveRequest(requestId, userId)
|
|
||||||
if (req.rowCount === 0) {
|
|
||||||
return 404
|
|
||||||
}
|
|
||||||
await pool.query(`
|
|
||||||
INSERT INTO mpj.history
|
|
||||||
("requestId", "asOf", "status", "text")
|
|
||||||
VALUES
|
|
||||||
($1, $2, $3, NULLIF($4, ''))`,
|
|
||||||
[ requestId, Date.now(), status, updateText ])
|
|
||||||
return 204
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new prayer request
|
|
||||||
* @param {string} userId The Id of the user
|
|
||||||
* @param {string} requestText The text of the request
|
|
||||||
* @return The created request
|
|
||||||
*/
|
|
||||||
addNew: async (userId, requestText) => {
|
|
||||||
const id = cuid()
|
|
||||||
const enteredOn = Date.now()
|
|
||||||
return (async () => {
|
|
||||||
const client = await pool.connect()
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN')
|
|
||||||
await client.query(
|
|
||||||
'INSERT INTO mpj.request ("requestId", "enteredOn", "userId") VALUES ($1, $2, $3)',
|
|
||||||
[ id, enteredOn, userId ])
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO mpj.history ("requestId", "asOf", "status", "text") VALUES ($1, $2, 'Created', $3)`,
|
|
||||||
[ id, enteredOn, requestText ])
|
|
||||||
await client.query('COMMIT')
|
|
||||||
} catch (e) {
|
|
||||||
await client.query('ROLLBACK')
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
client.release()
|
|
||||||
}
|
|
||||||
return { requestId: id, text: requestText, asOf: enteredOn, lastStatus: 'Created' }
|
|
||||||
})().catch(e => {
|
|
||||||
console.error(e.stack)
|
|
||||||
return { requestId: '', text: 'error', asOf: 0, lastStatus: 'Errored' }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a note about a prayer request
|
|
||||||
* @param {string} userId The Id of the user to whom the request belongs
|
|
||||||
* @param {string} requestId The Id of the request to which the note applies
|
|
||||||
* @param {string} note The notes to add
|
|
||||||
* @return {number} 404 if the request is not found or does not belong to the given user, 204 if successful
|
|
||||||
*/
|
|
||||||
addNote: async (userId, requestId, note) => {
|
|
||||||
const req = retrieveRequest(requestId, userId)
|
|
||||||
if (req.rowCount === 0) {
|
|
||||||
return 404
|
|
||||||
}
|
|
||||||
await pool.query(`
|
|
||||||
INSERT INTO mpj.note
|
|
||||||
("requestId", "asOf", "notes")
|
|
||||||
VALUES
|
|
||||||
($1, $2, $3)`,
|
|
||||||
[ requestId, Date.now(), note ])
|
|
||||||
return 204
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all answered requests with their text as of the "Answered" status
|
|
||||||
* @param {string} userId The Id of the user for whom requests should be retrieved
|
|
||||||
* @return All requests
|
|
||||||
*/
|
|
||||||
answered: async (userId) =>
|
|
||||||
(await pool.query(`${currentRequestSql}
|
|
||||||
WHERE "userId" = $1
|
|
||||||
AND "lastStatus" = 'Answered'
|
|
||||||
ORDER BY "asOf" DESC`,
|
|
||||||
[ userId ])).rows,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the "current" version of a request by its Id
|
|
||||||
* @param {string} requestId The Id of the request to retrieve
|
|
||||||
* @param {string} userId The Id of the user to which the request belongs
|
|
||||||
* @return The request, or a request-like object indicating that the request was not found
|
|
||||||
*/
|
|
||||||
byId: async (userId, requestId) => {
|
|
||||||
const reqs = await pool.query(`${currentRequestSql}
|
|
||||||
WHERE "requestId" = $1
|
|
||||||
AND "userId" = $2`,
|
|
||||||
[ requestId, userId ])
|
|
||||||
return (0 < reqs.rowCount) ? reqs.rows[0] : requestNotFound
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a prayer request, including its full history, by its Id
|
|
||||||
* @param {string} userId The Id of the user to which the request belongs
|
|
||||||
* @param {string} requestId The Id of the request to retrieve
|
|
||||||
* @return The request, or a request-like object indicating that the request was not found
|
|
||||||
*/
|
|
||||||
fullById: async (userId, requestId) => {
|
|
||||||
const reqResults = await retrieveRequest(requestId, userId)
|
|
||||||
if (0 === reqResults.rowCount) {
|
|
||||||
return requestNotFound
|
|
||||||
}
|
|
||||||
const req = reqResults.rows[0]
|
|
||||||
const history = await pool.query(`
|
|
||||||
SELECT "asOf", "status", COALESCE("text", '') AS "text"
|
|
||||||
FROM mpj.history
|
|
||||||
WHERE "requestId" = $1
|
|
||||||
ORDER BY "asOf"`,
|
|
||||||
[ requestId ])
|
|
||||||
req.history = history.rows
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current requests for a user (i.e., their complete current journal)
|
|
||||||
* @param {string} userId The Id of the user
|
|
||||||
* @return The requests that make up the current journal
|
|
||||||
*/
|
|
||||||
journal: async userId => (await pool.query(`${journalSql} ORDER BY "asOf"`, [ userId ])).rows,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the notes for a request, most recent first
|
|
||||||
* @param {string} userId The Id of the user to whom the request belongs
|
|
||||||
* @param {string} requestId The Id of the request whose notes should be retrieved
|
|
||||||
* @return The notes for the request
|
|
||||||
*/
|
|
||||||
notesById: async (userId, requestId) => {
|
|
||||||
const reqResults = await retrieveRequest(requestId, userId)
|
|
||||||
if (0 === reqResults.rowCount) {
|
|
||||||
return requestNotFound
|
|
||||||
}
|
|
||||||
const notes = await pool.query(`
|
|
||||||
SELECT "asOf", "notes"
|
|
||||||
FROM mpj.note
|
|
||||||
WHERE "requestId" = $1
|
|
||||||
ORDER BY "asOf" DESC`,
|
|
||||||
[ requestId ])
|
|
||||||
return notes.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import Koa from 'koa'
|
|
||||||
import bodyParser from 'koa-bodyparser'
|
|
||||||
import morgan from 'koa-morgan'
|
|
||||||
import send from 'koa-send'
|
|
||||||
import serveFrom from 'koa-static'
|
|
||||||
|
|
||||||
import appConfig from '../appsettings.json'
|
|
||||||
import router from './routes'
|
|
||||||
|
|
||||||
/** Koa app */
|
|
||||||
const app = new Koa()
|
|
||||||
|
|
||||||
if (appConfig.env === 'dev') app.use(morgan('dev'))
|
|
||||||
|
|
||||||
export default app
|
|
||||||
// Serve the Vue files from /public
|
|
||||||
.use(serveFrom('public'))
|
|
||||||
// Parse the body into ctx.request.body, if present
|
|
||||||
.use(bodyParser())
|
|
||||||
// Tie in all the routes
|
|
||||||
.use(router.routes())
|
|
||||||
.use(router.allowedMethods())
|
|
||||||
// Send the index.html file for what would normally get a 404
|
|
||||||
.use(async (ctx, next) => {
|
|
||||||
if (ctx.url.indexOf('/api') === -1) {
|
|
||||||
try {
|
|
||||||
await send(ctx, 'index.html', { root: __dirname + '/../public/' })
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
return await next(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await next()
|
|
||||||
})
|
|
|
@ -1,39 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import jwt from 'koa-jwt'
|
|
||||||
import jwksRsa from 'jwks-rsa-koa'
|
|
||||||
import Router from 'koa-router'
|
|
||||||
|
|
||||||
import appConfig from '../../appsettings.json'
|
|
||||||
import journal from './journal'
|
|
||||||
import request from './request'
|
|
||||||
|
|
||||||
/** Authentication middleware to verify the access token against the Auth0 JSON Web Key Set */
|
|
||||||
const checkJwt = jwt({
|
|
||||||
// Dynamically provide a signing key
|
|
||||||
// based on the kid in the header and
|
|
||||||
// the singing keys provided by the JWKS endpoint.
|
|
||||||
secret: jwksRsa.koaJwt2Key({
|
|
||||||
cache: true,
|
|
||||||
rateLimit: true,
|
|
||||||
jwksRequestsPerMinute: 5,
|
|
||||||
jwksUri: `https://${appConfig.auth0.domain}/.well-known/jwks.json`
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Validate the audience and the issuer.
|
|
||||||
audience: appConfig.auth0.clientId,
|
|
||||||
issuer: `https://${appConfig.auth0.domain}/`,
|
|
||||||
algorithms: ['RS256']
|
|
||||||
})
|
|
||||||
|
|
||||||
/** /api/journal routes */
|
|
||||||
const journalRoutes = journal(checkJwt)
|
|
||||||
/** /api/request routes */
|
|
||||||
const requestRoutes = request(checkJwt)
|
|
||||||
|
|
||||||
/** Combined router */
|
|
||||||
const router = new Router({ prefix: '/api' })
|
|
||||||
router.use('/journal', journalRoutes.routes(), journalRoutes.allowedMethods())
|
|
||||||
router.use('/request', requestRoutes.routes(), requestRoutes.allowedMethods())
|
|
||||||
|
|
||||||
export default router
|
|
|
@ -1,16 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import Router from 'koa-router'
|
|
||||||
import db from '../db'
|
|
||||||
|
|
||||||
const router = new Router()
|
|
||||||
|
|
||||||
export default function (checkJwt) {
|
|
||||||
|
|
||||||
router.get('/', checkJwt, async (ctx, next) => {
|
|
||||||
const reqs = await db.request.journal(ctx.state.user.sub)
|
|
||||||
ctx.body = reqs
|
|
||||||
return await next()
|
|
||||||
})
|
|
||||||
return router
|
|
||||||
}
|
|
|
@ -1,79 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import Router from 'koa-router'
|
|
||||||
import db from '../db'
|
|
||||||
|
|
||||||
const router = new Router()
|
|
||||||
|
|
||||||
export default function (checkJwt) {
|
|
||||||
|
|
||||||
router
|
|
||||||
// Add a new request
|
|
||||||
.post('/', checkJwt, async (ctx, next) => {
|
|
||||||
ctx.body = await db.request.addNew(ctx.state.user.sub, ctx.request.body.requestText)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Add a request history entry (prayed, updated, answered, etc.)
|
|
||||||
.post('/:id/history', checkJwt, async (ctx, next) => {
|
|
||||||
const body = ctx.request.body
|
|
||||||
ctx.response.status = await db.request.addHistory(ctx.state.user.sub, ctx.params.id, body.status, body.updateText)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Add a note to a request
|
|
||||||
.post('/:id/note', checkJwt, async (ctx, next) => {
|
|
||||||
const body = ctx.request.body
|
|
||||||
ctx.response.status = await db.request.addNote(ctx.state.user.sub, ctx.params.id, body.notes)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Get a journal-style request by its Id
|
|
||||||
.get('/:id', checkJwt, async (ctx, next) => {
|
|
||||||
const req = await db.request.byId(ctx.state.user.sub, ctx.params.id)
|
|
||||||
if ('Not Found' === req.text) {
|
|
||||||
ctx.response.status = 404
|
|
||||||
} else {
|
|
||||||
ctx.body = req
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Get a request, along with its full history
|
|
||||||
.get('/:id/full', checkJwt, async (ctx, next) => {
|
|
||||||
const req = await db.request.fullById(ctx.state.user.sub, ctx.params.id)
|
|
||||||
if ('Not Found' === req.text) {
|
|
||||||
ctx.response.status = 404
|
|
||||||
} else {
|
|
||||||
ctx.body = req
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Get the notes for a request
|
|
||||||
.get('/:id/notes', checkJwt, async (ctx, next) => {
|
|
||||||
const notes = await db.request.notesById(ctx.state.user.sub, ctx.params.id)
|
|
||||||
if (notes.text && 'Not Found' === notes.text) {
|
|
||||||
ctx.response.status = 404
|
|
||||||
} else {
|
|
||||||
ctx.body = notes
|
|
||||||
ctx.response.status = 200
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Get a complete request; equivalent to full + notes
|
|
||||||
.get('/:id/complete', checkJwt, async (ctx, next) => {
|
|
||||||
const req = await db.request.fullById(ctx.state.user.sub, ctx.params.id)
|
|
||||||
if ('Not Found' === req.text) {
|
|
||||||
ctx.response.status = 404
|
|
||||||
} else {
|
|
||||||
ctx.response.status = 200
|
|
||||||
req.notes = await db.request.notesById(ctx.state.user.sub, ctx.params.id)
|
|
||||||
ctx.body = req
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
// Get all answered requests
|
|
||||||
.get('/answered', checkJwt, async (ctx, next) => {
|
|
||||||
ctx.body = await db.request.answered(ctx.state.user.sub)
|
|
||||||
ctx.response.status = 200
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
return router
|
|
||||||
}
|
|
2224
src/api/yarn.lock
2224
src/api/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -4,8 +4,8 @@ var path = require('path')
|
||||||
module.exports = {
|
module.exports = {
|
||||||
build: {
|
build: {
|
||||||
env: require('./prod.env'),
|
env: require('./prod.env'),
|
||||||
index: path.resolve(__dirname, '../../api/public/index.html'),
|
index: path.resolve(__dirname, '../../public/index.html'),
|
||||||
assetsRoot: path.resolve(__dirname, '../../api/public'),
|
assetsRoot: path.resolve(__dirname, '../../public'),
|
||||||
assetsSubDirectory: 'static',
|
assetsSubDirectory: 'static',
|
||||||
assetsPublicPath: '/',
|
assetsPublicPath: '/',
|
||||||
productionSourceMap: true,
|
productionSourceMap: true,
|
||||||
|
|
|
@ -11,7 +11,9 @@
|
||||||
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
|
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
|
||||||
"e2e": "node test/e2e/runner.js",
|
"e2e": "node test/e2e/runner.js",
|
||||||
"test": "npm run unit && npm run e2e",
|
"test": "npm run unit && npm run e2e",
|
||||||
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"auth0-js": "^9.3.3",
|
"auth0-js": "^9.3.3",
|
||||||
|
|
|
@ -3,6 +3,8 @@ article
|
||||||
page-title(title='Answered Requests')
|
page-title(title='Answered Requests')
|
||||||
p(v-if='!loaded') Loading answered requests...
|
p(v-if='!loaded') Loading answered requests...
|
||||||
div(v-if='loaded').mpj-answered-list
|
div(v-if='loaded').mpj-answered-list
|
||||||
|
p.text-center(v-if='requests.length === 0'): em.
|
||||||
|
No answered requests found; once you have marked one as “Answered”, it will appear here
|
||||||
p.mpj-request-text(v-for='req in requests' :key='req.requestId')
|
p.mpj-request-text(v-for='req in requests' :key='req.requestId')
|
||||||
| {{ req.text }}
|
| {{ req.text }}
|
||||||
br
|
br
|
||||||
|
|
|
@ -48,7 +48,7 @@ export default {
|
||||||
.sort(asOfDesc)[0].text
|
.sort(asOfDesc)[0].text
|
||||||
},
|
},
|
||||||
log () {
|
log () {
|
||||||
return this.request.notes
|
return (this.request.notes || [])
|
||||||
.map(note => ({ asOf: note.asOf, text: note.notes, status: 'Notes' }))
|
.map(note => ({ asOf: note.asOf, text: note.notes, status: 'Notes' }))
|
||||||
.concat(this.request.history)
|
.concat(this.request.history)
|
||||||
.sort(asOfDesc)
|
.sort(asOfDesc)
|
||||||
|
|
|
@ -11,7 +11,8 @@ article
|
||||||
:request='request'
|
:request='request'
|
||||||
:events='eventBus'
|
:events='eventBus'
|
||||||
:toast='toast')
|
:toast='toast')
|
||||||
p.text-center(v-if='journal.length === 0'): em No requests found; click the "Add a New Request" button to add one
|
p.text-center(v-if='journal.length === 0'): em.
|
||||||
|
No requests found; click the “Add a New Request” button to add one
|
||||||
edit-request(:events='eventBus'
|
edit-request(:events='eventBus'
|
||||||
:toast='toast')
|
:toast='toast')
|
||||||
notes-edit(:events='eventBus'
|
notes-edit(:events='eventBus'
|
||||||
|
@ -50,7 +51,7 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
title () {
|
title () {
|
||||||
return `${this.user.given_name}'s Prayer Journal`
|
return `${this.user.given_name}’s Prayer Journal`
|
||||||
},
|
},
|
||||||
journalCardRows () {
|
journalCardRows () {
|
||||||
return chunk(this.journal, 3)
|
return chunk(this.journal, 3)
|
||||||
|
|
|
@ -18,11 +18,11 @@ export default {
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
title () {
|
title () {
|
||||||
document.title = `${this.title} « myPrayerJournal`
|
document.title = `${this.title.replace('’', "'")} « myPrayerJournal`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
document.title = `${this.title} « myPrayerJournal`
|
document.title = `${this.title.replace('’', "'")} « myPrayerJournal`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
48
src/my-prayer-journal.go
Normal file
48
src/my-prayer-journal.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
// myPrayerJournal API Server
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/danieljsummers/myPrayerJournal/src/api/data"
|
||||||
|
"github.com/danieljsummers/myPrayerJournal/src/api/routes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Web contains configuration for the web server.
|
||||||
|
type Web struct {
|
||||||
|
Port string `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings contains configuration for the myPrayerJournal API.
|
||||||
|
type Settings struct {
|
||||||
|
Data *data.Settings `json:"data"`
|
||||||
|
Web *Web `json:"web"`
|
||||||
|
Auth *routes.AuthConfig `json:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSettings parses the JSON configuration file into the Settings struct.
|
||||||
|
func readSettings(f string) *Settings {
|
||||||
|
config, err := os.Open(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer config.Close()
|
||||||
|
settings := Settings{}
|
||||||
|
if err = json.NewDecoder(config).Decode(&settings); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return &settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := readSettings("config.json")
|
||||||
|
if ok := data.Connect(cfg.Data); !ok {
|
||||||
|
log.Fatal("Unable to connect to database; exiting")
|
||||||
|
}
|
||||||
|
data.EnsureDB()
|
||||||
|
log.Printf("myPrayerJournal API listening on %s", cfg.Web.Port)
|
||||||
|
log.Fatal(http.ListenAndServe(cfg.Web.Port, routes.NewRouter(cfg.Auth)))
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user