From 695e70a1748fbe780ec2972bc7815e68c0da2648 Mon Sep 17 00:00:00 2001 From: Wilson Wong Date: Mon, 12 Feb 2024 09:43:45 -0800 Subject: [PATCH 1/3] feat: add 'note' table to db, models, types for log notes feature, wip --- app/src/db/migrations/20231212000000_init.ts | 34 +++++++++++++ app/src/db/models/index.ts | 1 + app/src/db/models/note.ts | 52 ++++++++++++++++++++ app/src/db/prisma/schema.prisma | 14 ++++++ app/src/types/Note.ts | 9 ++++ app/src/types/index.ts | 1 + frontend/src/types/Note.ts | 9 ++++ frontend/src/types/index.ts | 1 + 8 files changed, 121 insertions(+) create mode 100644 app/src/db/models/note.ts create mode 100644 app/src/types/Note.ts create mode 100644 frontend/src/types/Note.ts diff --git a/app/src/db/migrations/20231212000000_init.ts b/app/src/db/migrations/20231212000000_init.ts index bd48f34e..1a48343e 100644 --- a/app/src/db/migrations/20231212000000_init.ts +++ b/app/src/db/migrations/20231212000000_init.ts @@ -89,6 +89,7 @@ export async function up(knex: Knex): Promise { table.boolean('aaiUpdated'); table.text('waitingOn'); table.timestamp('bringForwardDate', { useTz: true }); + // TODO: REMOVE THIS WHEN DONE NOTE LOGS table.text('notes'); table.text('intakeStatus'); table.text('applicationStatus'); @@ -192,6 +193,30 @@ export async function up(knex: Knex): Promise { for each row execute procedure public.set_updatedAt();`) ) + .then(() => + knex.schema.createTable('note', (table) => { + table.uuid('note_id').primary(); + table + .uuid('submission_id') + .notNullable() + .references('submissionId') + .inTable('submission') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.text('category_type').defaultTo('').notNullable(); + table.text('note_type').defaultTo('').notNullable(); + table.text('note').defaultTo('').notNullable(); + stamps(knex, table); + table.unique(['note_id']); + }) + ) + + .then(() => + knex.schema.raw(`create trigger before_update_note_trigger + before update on public.note + for each row execute procedure public.set_updatedAt();`) + ) + // Create public schema functions .then(() => knex.schema.raw(`create or replace function public.get_activity_statistics( @@ -352,6 +377,12 @@ export async function up(knex: Knex): Promise { FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) ) + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_note_trigger + AFTER UPDATE OR DELETE ON note + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + // Populate Baseline Data .then(() => { const users = ['system']; @@ -620,6 +651,7 @@ export async function down(knex: Knex): Promise { .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_submission_trigger ON submission')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_user_trigger ON "user"')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_identity_provider_trigger ON identity_provider')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_note_trigger ON note')) // Drop audit schema and logged_actions table .then(() => knex.schema.raw('DROP FUNCTION IF EXISTS audit.if_modified_func')) .then(() => knex.schema.withSchema('audit').dropTableIfExists('logged_actions')) @@ -642,6 +674,8 @@ export async function down(knex: Knex): Promise { knex.schema.raw('DROP TRIGGER IF EXISTS before_update_identity_provider_trigger ON identity_provider') ) .then(() => knex.schema.dropTableIfExists('identity_provider')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_note_trigger ON note')) + .then(() => knex.schema.dropTableIfExists('note')) // Drop public schema triggers .then(() => knex.schema.raw('DROP FUNCTION IF EXISTS public.set_updatedAt')) ); diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index 3c178171..6e05e8b9 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,5 +1,6 @@ export { default as document } from './document'; export { default as identity_provider } from './identity_provider'; +export { default as note } from './note'; export { default as permit } from './permit'; export { default as permit_type } from './permit_type'; export { default as submission } from './submission'; diff --git a/app/src/db/models/note.ts b/app/src/db/models/note.ts new file mode 100644 index 00000000..c5586938 --- /dev/null +++ b/app/src/db/models/note.ts @@ -0,0 +1,52 @@ +import { Prisma } from '@prisma/client'; +import disconnectRelation from '../utils/disconnectRelation'; + +import type { IStamps } from '../../interfaces/IStamps'; +import type { Note } from '../../types'; + +// Define types +const _note = Prisma.validator()({}); +const _noteWithGraph = Prisma.validator()({}); + +type SubmissionRelation = { + submission: + | { + connect: { + submissionId: string; + }; + } + | { + disconnect: boolean; + }; +}; + +type PrismaRelationNote = Omit, 'submissionId' | keyof IStamps> & + SubmissionRelation; + +type PrismaGraphNote = Prisma.noteGetPayload; + +export default { + toPrismaModel(input: Note): PrismaRelationNote { + // Note: submissionId conversion to submission_id will be required here + return { + note_id: input.note_id as string, + submission: input.submission_id ? { connect: { submissionId: input.submission_id } } : disconnectRelation, + category_type: input.category_type, + note: input.note, + note_type: input.note_type + }; + }, + + fromPrismaModel(input: PrismaGraphNote | null): Note | null { + if (!input) return null; + + return { + note_id: input.note_id, + submission_id: input.submissionId as string, + category_type: input.category_type || '', + note: input.note || '', + note_type: input.note_type || '', + createdAt: input.createdAt?.toISOString() + }; + } +}; diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index 927f90e2..e541797e 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -44,6 +44,19 @@ model identity_provider { user user[] } +model note { + note_id String @id @unique(map: "note_note_id_unique") @db.Uuid + submissionId String @db.Uuid + category_type String? + note_type String? + note String? + createdBy String? @default("00000000-0000-0000-0000-000000000000") + createdAt DateTime? @default(now()) @db.Timestamptz(6) + updatedBy String? + updatedAt DateTime? @db.Timestamptz(6) + submission submission @relation(fields: [submissionId], references: [submissionId], onDelete: Cascade, map: "note_submissionid_foreign") +} + model permit { permitId String @id @db.Uuid permitTypeId Int @@ -132,6 +145,7 @@ model submission { updatedBy String? updatedAt DateTime? @db.Timestamptz(6) document document[] + note note[] permit permit[] user user? @relation(fields: [assignedToUserId], references: [userId], onDelete: Cascade, map: "submission_assignedtouserid_foreign") } diff --git a/app/src/types/Note.ts b/app/src/types/Note.ts new file mode 100644 index 00000000..97893fa0 --- /dev/null +++ b/app/src/types/Note.ts @@ -0,0 +1,9 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type Note = { + note_id: string; // Primary Key + submission_id: string; + category_type: string; + note: string; + note_type: string; +} & Partial; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 4fc0741c..026c7b3e 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -4,6 +4,7 @@ export type { ChefsSubmissionFormExport } from './ChefsSubmissionFormExport'; export type { CurrentUser } from './CurrentUser'; export type { Document } from './Document'; export type { IdentityProvider } from './IdentityProvider'; +export type { Note } from './Note'; export type { Permit } from './Permit'; export type { PermitType } from './PermitType'; export type { SubmissionSearchParameters } from './SubmissionSearchParameters'; diff --git a/frontend/src/types/Note.ts b/frontend/src/types/Note.ts new file mode 100644 index 00000000..a198deb5 --- /dev/null +++ b/frontend/src/types/Note.ts @@ -0,0 +1,9 @@ +import type { IStamps } from '@/interfaces'; + +export type Note = { + note_id: string; // Primary Key + submission_id: number; + note_type: string; + category_type?: string; + note?: string; +} & Partial; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5920b108..5eb3154e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,3 +5,4 @@ export type { Permit } from './Permit'; export type { PermitType } from './PermitType'; export type { User } from './User'; export type { UserSearchParameters } from './UserSearchParameters'; +export type { Note } from './Note'; From 17f1fcacbd2e5ec5acd84639c2b908eda78d1c1a Mon Sep 17 00:00:00 2001 From: Wilson Wong Date: Tue, 13 Feb 2024 15:45:26 -0800 Subject: [PATCH 2/3] feat: frontend modal and tab for notes page, submit functionality --- app/src/components/utils.ts | 2 +- app/src/controllers/index.ts | 1 + app/src/controllers/note.ts | 26 ++++ app/src/db/migrations/20231212000000_init.ts | 12 +- app/src/db/models/note.ts | 48 ++++---- app/src/db/prisma/schema.prisma | 16 ++- app/src/routes/v1/index.ts | 4 +- app/src/routes/v1/note.ts | 20 +++ app/src/services/index.ts | 1 + app/src/services/note.ts | 55 +++++++++ app/src/types/Note.ts | 15 ++- frontend/src/components/note/NoteCard.vue | 101 +++++++++++++++ frontend/src/components/note/NoteModal.vue | 116 ++++++++++++++++++ .../components/submission/SubmissionForm.vue | 14 +-- frontend/src/services/index.ts | 1 + frontend/src/services/noteService.ts | 20 +++ frontend/src/services/userService.ts | 2 +- frontend/src/types/Note.ts | 16 ++- frontend/src/utils/constants.ts | 6 + frontend/src/utils/enums.ts | 8 ++ frontend/src/views/SubmissionView.vue | 53 +++++++- 21 files changed, 472 insertions(+), 65 deletions(-) create mode 100644 app/src/controllers/note.ts create mode 100644 app/src/routes/v1/note.ts create mode 100644 app/src/services/note.ts create mode 100644 frontend/src/components/note/NoteCard.vue create mode 100644 frontend/src/components/note/NoteModal.vue create mode 100644 frontend/src/services/noteService.ts diff --git a/app/src/components/utils.ts b/app/src/components/utils.ts index 94b74843..1def4dd1 100644 --- a/app/src/components/utils.ts +++ b/app/src/components/utils.ts @@ -128,7 +128,7 @@ export function mixedQueryToArray(param: string | Array): Array if (!param) return undefined; const parsed = Array.isArray(param) ? param.flatMap((p) => parseCSV(p)) : parseCSV(param); - const unique = [...new Set(parsed)]; + const unique = Array.from(new Set(parsed)); return unique.length ? unique : undefined; } diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index 5c9951c1..7008a8b4 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,4 +1,5 @@ export { default as chefsController } from './chefs'; export { default as documentController } from './document'; +export { default as noteController } from './note'; export { default as permitController } from './permit'; export { default as userController } from './user'; diff --git a/app/src/controllers/note.ts b/app/src/controllers/note.ts new file mode 100644 index 00000000..b0313f68 --- /dev/null +++ b/app/src/controllers/note.ts @@ -0,0 +1,26 @@ +import { noteService } from '../services'; + +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; +import { CurrentUser } from '../types'; + +const controller = { + createNote: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await noteService.createNote(req.body, req.currentUser as CurrentUser); + res.status(200).send(response); + } catch (e: unknown) { + next(e); + } + }, + + async listNotes(req: Request<{ submissionId: string }>, res: Response, next: NextFunction) { + try { + const response = await noteService.listNotes(req.params.submissionId); + res.status(200).send(response); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/db/migrations/20231212000000_init.ts b/app/src/db/migrations/20231212000000_init.ts index 1a48343e..82188b92 100644 --- a/app/src/db/migrations/20231212000000_init.ts +++ b/app/src/db/migrations/20231212000000_init.ts @@ -89,7 +89,6 @@ export async function up(knex: Knex): Promise { table.boolean('aaiUpdated'); table.text('waitingOn'); table.timestamp('bringForwardDate', { useTz: true }); - // TODO: REMOVE THIS WHEN DONE NOTE LOGS table.text('notes'); table.text('intakeStatus'); table.text('applicationStatus'); @@ -203,10 +202,11 @@ export async function up(knex: Knex): Promise { .inTable('submission') .onUpdate('CASCADE') .onDelete('CASCADE'); - table.text('category_type').defaultTo('').notNullable(); - table.text('note_type').defaultTo('').notNullable(); table.text('note').defaultTo('').notNullable(); - stamps(knex, table); + table.text('note_type').defaultTo('').notNullable(); + table.text('title').defaultTo('').notNullable(); + table.text('createdAt'); + table.text('createdBy'); table.unique(['note_id']); }) ) @@ -659,6 +659,8 @@ export async function down(knex: Knex): Promise { // Drop public schema functions .then(() => knex.schema.raw('DROP FUNCTION IF EXISTS public.get_activity_statistics')) // Drop public schema tables and triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_note_trigger ON note')) + .then(() => knex.schema.dropTableIfExists('note')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_permit_trigger ON permit')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_insert_permit_trigger ON permit')) .then(() => knex.schema.dropTableIfExists('permit')) @@ -674,8 +676,6 @@ export async function down(knex: Knex): Promise { knex.schema.raw('DROP TRIGGER IF EXISTS before_update_identity_provider_trigger ON identity_provider') ) .then(() => knex.schema.dropTableIfExists('identity_provider')) - .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_note_trigger ON note')) - .then(() => knex.schema.dropTableIfExists('note')) // Drop public schema triggers .then(() => knex.schema.raw('DROP FUNCTION IF EXISTS public.set_updatedAt')) ); diff --git a/app/src/db/models/note.ts b/app/src/db/models/note.ts index c5586938..8aade961 100644 --- a/app/src/db/models/note.ts +++ b/app/src/db/models/note.ts @@ -1,27 +1,23 @@ import { Prisma } from '@prisma/client'; -import disconnectRelation from '../utils/disconnectRelation'; +import { default as submission } from './submission'; -import type { IStamps } from '../../interfaces/IStamps'; -import type { Note } from '../../types'; +import type { ChefsSubmissionForm, Note } from '../../types'; // Define types const _note = Prisma.validator()({}); -const _noteWithGraph = Prisma.validator()({}); +const _noteWithGraph = Prisma.validator()({ + include: { submission: { include: { user: true } } } +}); type SubmissionRelation = { - submission: - | { - connect: { - submissionId: string; - }; - } - | { - disconnect: boolean; - }; + submission: { + connect: { + submissionId: string; + }; + }; }; -type PrismaRelationNote = Omit, 'submissionId' | keyof IStamps> & - SubmissionRelation; +type PrismaRelationNote = Omit, 'submission_id'> & SubmissionRelation; type PrismaGraphNote = Prisma.noteGetPayload; @@ -29,11 +25,13 @@ export default { toPrismaModel(input: Note): PrismaRelationNote { // Note: submissionId conversion to submission_id will be required here return { - note_id: input.note_id as string, - submission: input.submission_id ? { connect: { submissionId: input.submission_id } } : disconnectRelation, - category_type: input.category_type, + note_id: input.noteId as string, note: input.note, - note_type: input.note_type + note_type: input.noteType, + submission: { connect: { submissionId: input.submissionId } }, + title: input.title, + createdAt: input.createdAt ?? new Date().toISOString(), + createdBy: input.createdBy }; }, @@ -41,12 +39,14 @@ export default { if (!input) return null; return { - note_id: input.note_id, - submission_id: input.submissionId as string, - category_type: input.category_type || '', + noteId: input.note_id, note: input.note || '', - note_type: input.note_type || '', - createdAt: input.createdAt?.toISOString() + noteType: input.note_type || '', + submission: submission.fromPrismaModel(input.submission) as ChefsSubmissionForm, + submissionId: input.submission_id as string, + title: input.title || '', + createdAt: input.createdAt, + createdBy: input.createdBy }; } }; diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index e541797e..a27117f3 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -46,15 +46,13 @@ model identity_provider { model note { note_id String @id @unique(map: "note_note_id_unique") @db.Uuid - submissionId String @db.Uuid - category_type String? - note_type String? - note String? - createdBy String? @default("00000000-0000-0000-0000-000000000000") - createdAt DateTime? @default(now()) @db.Timestamptz(6) - updatedBy String? - updatedAt DateTime? @db.Timestamptz(6) - submission submission @relation(fields: [submissionId], references: [submissionId], onDelete: Cascade, map: "note_submissionid_foreign") + submission_id String @db.Uuid + note String @default("") + note_type String @default("") + title String @default("") + createdAt String? + createdBy String? + submission submission @relation(fields: [submission_id], references: [submissionId], onDelete: Cascade, map: "note_submission_id_foreign") } model permit { diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index b68e9e88..ee6622f3 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -3,6 +3,7 @@ import { hasAccess } from '../../middleware/authorization'; import express from 'express'; import chefs from './chefs'; import document from './document'; +import note from './note'; import permit from './permit'; import user from './user'; @@ -13,12 +14,13 @@ router.use(hasAccess); // Base v1 Responder router.get('/', (_req, res) => { res.status(200).json({ - endpoints: ['/chefs', '/document', '/permit', '/user'] + endpoints: ['/chefs', '/document', '/note', '/permit', '/user'] }); }); router.use('/chefs', chefs); router.use('/document', document); +router.use('/note', note); router.use('/permit', permit); router.use('/user', user); diff --git a/app/src/routes/v1/note.ts b/app/src/routes/v1/note.ts new file mode 100644 index 00000000..a8d7d536 --- /dev/null +++ b/app/src/routes/v1/note.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { noteController } from '../../controllers'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; + +import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; + +const router = express.Router(); +router.use(requireSomeAuth); + +// note create endpoint +router.put('/', (req: Request, res: Response, next: NextFunction): void => { + noteController.createNote(req, res, next); +}); + +// note list by submission endpoint +router.get('/list/:submissionId', (req: Request, res: Response, next: NextFunction): void => { + noteController.listNotes(req, res, next); +}); + +export default router; diff --git a/app/src/services/index.ts b/app/src/services/index.ts index f04b71ab..a678c5ba 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1,4 +1,5 @@ export { default as chefsService } from './chefs'; export { default as documentService } from './document'; +export { default as noteService } from './note'; export { default as permitService } from './permit'; export { default as userService } from './user'; diff --git a/app/src/services/note.ts b/app/src/services/note.ts new file mode 100644 index 00000000..4e9667fd --- /dev/null +++ b/app/src/services/note.ts @@ -0,0 +1,55 @@ +import prisma from '../db/dataConnection'; +import { note } from '../db/models'; +import { v4 as uuidv4 } from 'uuid'; +import { addDashesToUuid } from '../components/utils'; + +import type { CurrentUser, Note } from '../types'; +import { JwtPayload } from 'jsonwebtoken'; + +const service = { + /** + * @function createNote + * Creates a Permit + * @param note Note Object + * @returns {Promise} The result of running the findUnique operation + */ + createNote: async (data: Note, currentUser: CurrentUser) => { + const newNote = { + ...data, + createdBy: addDashesToUuid((currentUser.tokenPayload as JwtPayload)?.idir_user_guid), + noteId: uuidv4() + }; + const create = await prisma.note.create({ + include: { + submission: { + include: { user: true } + } + }, + data: note.toPrismaModel(newNote) + }); + + return note.fromPrismaModel(create); + }, + + /** + * @function listNotes + * Retrieve a list of permits associated with a given submission + * @param submissionId PCNS Submission ID + * @returns {Promise} Array of documents associated with the submission + */ + listNotes: async (submissionId: string) => { + const response = await prisma.note.findMany({ + include: { + submission: { + include: { user: true } + } + }, + where: { + submission_id: submissionId + } + }); + return response.map((x) => note.fromPrismaModel(x)); + } +}; + +export default service; diff --git a/app/src/types/Note.ts b/app/src/types/Note.ts index 97893fa0..823ab994 100644 --- a/app/src/types/Note.ts +++ b/app/src/types/Note.ts @@ -1,9 +1,12 @@ -import { IStamps } from '../interfaces/IStamps'; +import type { ChefsSubmissionForm } from './ChefsSubmissionForm'; export type Note = { - note_id: string; // Primary Key - submission_id: string; - category_type: string; + noteId: string; // Primary Key + submissionId: string; note: string; - note_type: string; -} & Partial; + noteType: string; + submission: ChefsSubmissionForm; + title: string; + createdAt: string | null; + createdBy: string | null; +}; diff --git a/frontend/src/components/note/NoteCard.vue b/frontend/src/components/note/NoteCard.vue new file mode 100644 index 00000000..23088705 --- /dev/null +++ b/frontend/src/components/note/NoteCard.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/note/NoteModal.vue b/frontend/src/components/note/NoteModal.vue new file mode 100644 index 00000000..a58617b1 --- /dev/null +++ b/frontend/src/components/note/NoteModal.vue @@ -0,0 +1,116 @@ + + +