Added Notes feature (#8)

Also:
- Moved buttons to top of request
- Tweaked layout of full request view
- Added code to ensure that users may only add history and notes to
their own requests; security FTW!
This commit is contained in:
Daniel J. Summers 2017-10-09 21:39:40 -05:00
parent b6d72d691b
commit 69811cbf54
10 changed files with 275 additions and 48 deletions

View File

@ -28,6 +28,10 @@ The first button for each request has a checkmark icon; clicking this button wil
The second button for each request has a pencil icon. This allows you to edit the text of the request, pretty much the same way you entered it; it starts with the current text, and you can add to it, modify it, or completely replace it. By default, updates will go in with an "Updated" status; you have the option to also mark this update as "Prayed" or "Answered." Answered requests will drop off the journal list.
## Adding Notes
The third button for each request has an icon that looks like a piece of paper with writing; this lets you record notes about the request. If there is something you want to record that doesn't change the text of the request, this is the place to do it. For example, you may be praying for a long-term health issue, and that person tells you that their status is the same; or, you may want to record something God said to you while you were praying for that request.
## Viewing a Request and Its History
myPrayerJournal tracks all of the actions related to a request; the fourth button, with the magnifying glass icon, will show you the entire history, including the text as it changed, and all the times "Prayed" was recorded.

View File

@ -46,6 +46,17 @@ const ddl = [
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'),

View File

@ -18,21 +18,41 @@ const requestNotFound = {
}
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} requestId The Id of the 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 (requestId, status, updateText) => {
const asOf = Date.now()
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, asOf, status, updateText ])
[ requestId, Date.now(), status, updateText ])
return 204
},
/**
@ -68,6 +88,27 @@ export default function (pool) {
})
},
/**
* 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
@ -101,12 +142,7 @@ export default function (pool) {
* @return The request, or a request-like object indicating that the request was not found
*/
fullById: async (userId, requestId) => {
const reqResults = await pool.query(`
SELECT "requestId", "enteredOn"
FROM mpj.request
WHERE "requestId" = $1
AND "userId" = $2`,
[ requestId, userId ])
const reqResults = await retrieveRequest(requestId, userId)
if (0 === reqResults.rowCount) {
return requestNotFound
}
@ -126,7 +162,27 @@ export default function (pool) {
* @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
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
}
}
}

View File

@ -16,8 +16,13 @@ export default function (checkJwt) {
// Add a request history entry (prayed, updated, answered, etc.)
.post('/:id/history', checkJwt, async (ctx, next) => {
const body = ctx.request.body
await db.request.addHistory(ctx.params.id, body.status, body.updateText)
ctx.response.status = 204
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
@ -40,6 +45,17 @@ export default function (checkJwt) {
}
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('/answered', checkJwt, async (ctx, next) => {
ctx.body = await db.request.answered(ctx.state.user.sub)
ctx.response.status = 200

View File

@ -25,6 +25,13 @@ export default {
*/
journal: () => http.get('journal/'),
/**
* Add a note for a prayer request
* @param {string} requestId The Id of the request to which the note applies
* @param {string} notes The notes to be added
*/
addNote: (requestId, notes) => http.post(`request/${requestId}/note`, { notes }),
/**
* Add a new prayer request
* @param {string} requestText The text of the request to be added
@ -55,6 +62,12 @@ export default {
/**
* Get all answered requests, along with the text they had when it was answered
*/
getAnsweredRequests: () => http.get('request/answered')
getAnsweredRequests: () => http.get('request/answered'),
/**
* Get past notes for a prayer request
* @param {string} requestId The Id of the request for which notes should be retrieved
*/
getNotes: requestId => http.get(`request/${requestId}/notes`)
}

View File

@ -14,6 +14,8 @@ article
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'
:toast='toast')
full-request(:events='eventBus')
</template>
@ -27,6 +29,7 @@ import chunk from 'lodash/chunk'
import EditRequest from './request/EditRequest'
import FullRequest from './request/FullRequest'
import NewRequest from './request/NewRequest'
import NotesEdit from './request/NotesEdit'
import RequestListItem from './request/RequestListItem'
import actions from '@/store/action-types'
@ -37,6 +40,7 @@ export default {
EditRequest,
FullRequest,
NewRequest,
NotesEdit,
RequestListItem
},
data () {

View File

@ -1,5 +1,4 @@
<template lang="pug">
span
b-modal(v-model='editVisible'
header-bg-variant='mpj'
header-text-variant='light'

View File

@ -1,7 +1,9 @@
<template lang="pug">
b-list-group-item
| {{ history.status }} {{ asOf }}
span(v-if='0 < history.text.length') &nbsp;&raquo; {{ history.text }}
| {{ history.status }}
|
small.text-muted {{ asOf }}
div(v-if='hasText').mpj-request-text {{ history.text }}
</template>
<script>
@ -17,6 +19,9 @@ export default {
computed: {
asOf () {
return moment(this.history.asOf).fromNow()
},
hasText () {
return this.history.text.length > 0
}
}
}

View File

@ -0,0 +1,116 @@
<template lang="pug">
b-modal(v-model='notesVisible'
header-bg-variant='mpj'
header-text-variant='light'
size='lg'
title='Add Notes to Prayer Request'
@edit='openDialog()'
@shows='focusNotes')
b-form
b-form-group(label='Notes'
label-for='notes')
b-textarea#notes(ref='toFocus'
v-model='form.notes'
:rows='10'
@blur='trimText()')
div(v-if='hasPriorNotes')
p.text-center: strong Prior Notes for This Request
b-list-group(flush)
b-list-group-item(v-for='note in priorNotes'
:key='note.asOf')
small.text-muted: date-from-now(:value='note.asOf')
br
div.mpj-request-text {{ note.notes }}
div(v-if='noPriorNotes').text-center.text-muted There are no prior notes for this request
div(v-if='!priorNotesLoaded').text-center
b-btn(variant='outline-secondary'
@click='loadNotes()') Load Prior Notes
div.w-100.text-right(slot='modal-footer')
b-btn(variant='primary'
@click='saveNotes()') Save
| &nbsp; &nbsp;
b-btn(variant='outline-secondary'
@click='closeDialog()') Cancel
</template>
<script>
'use strict'
import api from '@/api'
export default {
name: 'notes-edit',
props: {
toast: { required: true },
events: { required: true }
},
data () {
return {
notesVisible: false,
form: {
requestId: '',
notes: ''
},
priorNotes: [],
priorNotesLoaded: false
}
},
computed: {
hasPriorNotes () {
return this.priorNotesLoaded && this.priorNotes.length > 0
},
noPriorNotes () {
return this.priorNotesLoaded && this.priorNotes.length === 0
}
},
created () {
this.events.$on('notes', this.openDialog)
},
methods: {
closeDialog () {
this.form.requestId = ''
this.form.notes = ''
this.priorNotes = []
this.priorNotesLoaded = false
this.notesVisible = false
},
focusNotes (e) {
this.$refs.toFocus.focus()
},
async loadNotes () {
this.$Progress.start()
try {
const notes = await api.getNotes(this.form.requestId)
this.priorNotes = notes.data
console.log(this.priorNotes)
this.$Progress.finish()
} catch (e) {
console.error(e)
this.$Progress.fail()
} finally {
this.priorNotesLoaded = true
}
},
openDialog (request) {
this.form.requestId = request.requestId
this.notesVisible = true
this.focusNotes(null)
},
async saveNotes () {
this.$Progress.start()
try {
await api.addNote(this.form.requestId, this.form.notes)
this.$Progress.finish()
this.toast.showToast('Added notes', { theme: 'success' })
this.closeDialog()
} catch (e) {
console.error(e)
this.$Progress.fail()
}
},
trimText () {
this.form.notes = this.form.notes.trim()
}
}
}
</script>

View File

@ -5,6 +5,11 @@ div
:key='request.requestId'
border-variant='dark'
no-body)
b-card-header.text-center.py-1.
#[b-btn(@click='markPrayed(idx)' variant='outline-primary' title='Pray' size='sm'): icon(name='check')]
#[b-btn(@click.stop='showEdit(request)' variant='outline-secondary' title='Edit' size='sm'): icon(name='pencil')]
#[b-btn(@click.stop='showNotes(request)' variant='outline-secondary' title='Add Notes' size='sm'): icon(name='file-text-o')]
#[b-btn(@click.stop='showFull(idx)' variant='outline-secondary' title='View Full Request' size='sm'): icon(name='search')]
b-card-body.p-0
p.card-text.mpj-request-text.mb-1.px-3.pt-3
| {{ request.text }}
@ -12,11 +17,6 @@ div
= '(last activity '
date-from-now(:value='request.asOf')
| )
b-card-footer.text-center.py-1.
#[b-btn(@click='markPrayed(idx)' variant='outline-primary' title='Pray' size='sm'): icon(name='check')]
#[b-btn(@click.stop='showEdit(request)' variant='outline-secondary' title='Edit' size='sm'): icon(name='pencil')]
#[b-btn(disabled variant='outline-secondary' title='Add Notes' size='sm'): icon(name='file-text-o')]
#[b-btn(@click.stop='showFull(idx)' variant='outline-secondary' title='View Full Request' size='sm'): icon(name='search')]
b-card(v-for='it in 3 - row.length')
br
</template>
@ -48,6 +48,9 @@ export default {
},
showFull (idx) {
this.events.$emit('full', this.row[idx].requestId)
},
showNotes (request) {
this.events.$emit('notes', request)
}
}
}