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
|
||||
src/api/build
|
||||
src/api/public/index.html
|
||||
src/api/public/static
|
||||
src/api/appsettings.json
|
||||
/build
|
||||
src/public/index.html
|
||||
src/public/static
|
||||
src/config.json
|
||||
/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 = {
|
||||
build: {
|
||||
env: require('./prod.env'),
|
||||
index: path.resolve(__dirname, '../../api/public/index.html'),
|
||||
assetsRoot: path.resolve(__dirname, '../../api/public'),
|
||||
index: path.resolve(__dirname, '../../public/index.html'),
|
||||
assetsRoot: path.resolve(__dirname, '../../public'),
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
productionSourceMap: true,
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
|
||||
"e2e": "node test/e2e/runner.js",
|
||||
"test": "npm run unit && npm run e2e",
|
||||
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
|
||||
"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": {
|
||||
"auth0-js": "^9.3.3",
|
||||
|
|
|
@ -3,6 +3,8 @@ article
|
|||
page-title(title='Answered Requests')
|
||||
p(v-if='!loaded') Loading answered requests...
|
||||
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')
|
||||
| {{ req.text }}
|
||||
br
|
||||
|
|
|
@ -48,7 +48,7 @@ export default {
|
|||
.sort(asOfDesc)[0].text
|
||||
},
|
||||
log () {
|
||||
return this.request.notes
|
||||
return (this.request.notes || [])
|
||||
.map(note => ({ asOf: note.asOf, text: note.notes, status: 'Notes' }))
|
||||
.concat(this.request.history)
|
||||
.sort(asOfDesc)
|
||||
|
|
|
@ -11,7 +11,8 @@ article
|
|||
:request='request'
|
||||
:events='eventBus'
|
||||
: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'
|
||||
:toast='toast')
|
||||
notes-edit(:events='eventBus'
|
||||
|
@ -50,7 +51,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
title () {
|
||||
return `${this.user.given_name}'s Prayer Journal`
|
||||
return `${this.user.given_name}’s Prayer Journal`
|
||||
},
|
||||
journalCardRows () {
|
||||
return chunk(this.journal, 3)
|
||||
|
|
|
@ -18,11 +18,11 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
title () {
|
||||
document.title = `${this.title} « myPrayerJournal`
|
||||
document.title = `${this.title.replace('’', "'")} « myPrayerJournal`
|
||||
}
|
||||
},
|
||||
created () {
|
||||
document.title = `${this.title} « myPrayerJournal`
|
||||
document.title = `${this.title.replace('’', "'")} « myPrayerJournal`
|
||||
}
|
||||
}
|
||||
</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