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:
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
<template lang="pug">
|
||||
span
|
||||
b-modal(v-model='editVisible'
|
||||
header-bg-variant='mpj'
|
||||
header-text-variant='light'
|
||||
size='lg'
|
||||
title='Edit Prayer Request'
|
||||
@edit='openDialog()'
|
||||
@shows='focusRequestText')
|
||||
b-form
|
||||
b-form-group(label='Prayer Request'
|
||||
label-for='request_text')
|
||||
b-textarea#request_text(ref='toFocus'
|
||||
v-model='form.requestText'
|
||||
:rows='10'
|
||||
@blur='trimText()')
|
||||
b-form-group(label='Also Mark As')
|
||||
b-radio-group(v-model='form.status'
|
||||
buttons)
|
||||
b-radio(value='Updated') Updated
|
||||
b-radio(value='Prayed') Prayed
|
||||
b-radio(value='Answered') Answered
|
||||
div.w-100.text-right(slot='modal-footer')
|
||||
b-btn(variant='primary'
|
||||
@click='saveRequest()') Save
|
||||
|
|
||||
b-btn(variant='outline-secondary'
|
||||
@click='closeDialog()') Cancel
|
||||
b-modal(v-model='editVisible'
|
||||
header-bg-variant='mpj'
|
||||
header-text-variant='light'
|
||||
size='lg'
|
||||
title='Edit Prayer Request'
|
||||
@edit='openDialog()'
|
||||
@shows='focusRequestText')
|
||||
b-form
|
||||
b-form-group(label='Prayer Request'
|
||||
label-for='request_text')
|
||||
b-textarea#request_text(ref='toFocus'
|
||||
v-model='form.requestText'
|
||||
:rows='10'
|
||||
@blur='trimText()')
|
||||
b-form-group(label='Also Mark As')
|
||||
b-radio-group(v-model='form.status'
|
||||
buttons)
|
||||
b-radio(value='Updated') Updated
|
||||
b-radio(value='Prayed') Prayed
|
||||
b-radio(value='Answered') Answered
|
||||
div.w-100.text-right(slot='modal-footer')
|
||||
b-btn(variant='primary'
|
||||
@click='saveRequest()') Save
|
||||
|
|
||||
b-btn(variant='outline-secondary'
|
||||
@click='closeDialog()') Cancel
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template lang="pug">
|
||||
b-list-group-item
|
||||
| {{ history.status }} {{ asOf }}
|
||||
span(v-if='0 < history.text.length') » {{ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
src/app/src/components/request/NotesEdit.vue
Normal file
116
src/app/src/components/request/NotesEdit.vue
Normal 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
|
||||
|
|
||||
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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user