diff --git a/app/src/components/constants.ts b/app/src/components/constants.ts index a1cf538e..c51956b5 100644 --- a/app/src/components/constants.ts +++ b/app/src/components/constants.ts @@ -70,7 +70,8 @@ export const PERMIT_STATUS = Object.freeze({ /** Types of notes */ export const NOTE_TYPE_LIST = Object.freeze({ GENERAL: 'General', - BRING_FORWARD: 'Bring Forward' + BRING_FORWARD: 'Bring Forward', + ENQUIRY: 'Enquiry' }); /** diff --git a/app/src/controllers/enquiry.ts b/app/src/controllers/enquiry.ts new file mode 100644 index 00000000..b7cb952c --- /dev/null +++ b/app/src/controllers/enquiry.ts @@ -0,0 +1,118 @@ +import { NIL, v4 as uuidv4 } from 'uuid'; + +import { Initiatives, NOTE_TYPE_LIST } from '../components/constants'; +import { getCurrentIdentity } from '../components/utils'; +import { activityService, enquiryService, noteService, userService } from '../services'; + +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; +import type { Enquiry } from '../types'; + +const controller = { + createRelatedNote: async (req: Request, data: Enquiry) => { + if (data.relatedActivityId) { + const activity = await activityService.getActivity(data.relatedActivityId); + if (activity) { + const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, NIL), NIL); + + await noteService.createNote({ + activityId: data.relatedActivityId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, max-len + note: `Added by ${(req.currentUser?.tokenPayload as any)?.idir_username}\nEnquiry #${data.activityId}\n${data.enquiryDescription}`, + noteType: NOTE_TYPE_LIST.ENQUIRY, + title: 'Enquiry', + bringForwardDate: null, + bringForwardState: null, + createdAt: new Date().toISOString(), + createdBy: userId, + isDeleted: false + }); + } + } + }, + + generateEnquiryData: async (req: Request) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = req.body; + + const activityId = data.activityId ?? (await activityService.createActivity(Initiatives.HOUSING))?.activityId; + + let applicant, basic; + + // Create applicant information + if (data.applicant) { + applicant = { + contactFirstName: data.applicant.firstName, + contactLastName: data.applicant.lastName, + contactPhoneNumber: data.applicant.phoneNumber, + contactEmail: data.applicant.email, + contactApplicantRelationship: data.applicant.relationshipToProject, + contactPreference: data.applicant.contactPreference + }; + } + + if (data.basic) { + basic = { + enquiryType: data.basic.enquiryType, + isRelated: data.basic.isRelated, + relatedActivityId: data.basic.relatedActivityId, + enquiryDescription: data.basic.enquiryDescription, + applyForPermitConnect: data.basic.applyForPermitConnect + }; + } + + // Put new enquiry together + return { + ...applicant, + ...basic, + enquiryId: data.enquiryId ?? uuidv4(), + activityId: activityId, + submittedAt: data.submittedAt ?? new Date().toISOString(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + submittedBy: (req.currentUser?.tokenPayload as any)?.idir_username + }; + }, + + createDraft: async (req: Request, res: Response, next: NextFunction) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = req.body; + + const enquiry = await controller.generateEnquiryData(req); + + // Create new enquiry + const result = await enquiryService.createEnquiry(enquiry); + + // On submit attempt to create note if enquiry is associated with an existing activity + if (data.submit) { + await controller.createRelatedNote(req, result); + } + + res.status(201).json({ activityId: result.activityId, enquiryId: result.enquiryId }); + } catch (e: unknown) { + next(e); + } + }, + + updateDraft: async (req: Request, res: Response, next: NextFunction) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = req.body; + + const enquiry = await controller.generateEnquiryData(req); + + // Update enquiry + const result = await enquiryService.updateEnquiry(enquiry as Enquiry); + + // On submit, attempt to create note if enquiry is associated with an existing activity + if (data.submit) { + await controller.createRelatedNote(req, result); + } + + res.status(200).json({ activityId: result.activityId, enquiryId: result.enquiryId }); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index e3fc472e..8dedc117 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,4 +1,5 @@ export { default as documentController } from './document'; +export { default as enquiryController } from './enquiry'; export { default as noteController } from './note'; export { default as permitController } from './permit'; export { default as roadmapController } from './roadmap'; diff --git a/app/src/db/migrations/20240516000000_005-shas-enquiry.ts b/app/src/db/migrations/20240516000000_005-shas-enquiry.ts new file mode 100644 index 00000000..329316c3 --- /dev/null +++ b/app/src/db/migrations/20240516000000_005-shas-enquiry.ts @@ -0,0 +1,87 @@ +import stamps from '../stamps'; + +import type { Knex } from 'knex'; + +/* + * Not included in this migration is a manual destructive operation + * submission.contact_name will be split into two new parts + * submission.contact_first_name + * submission.contact_last_name + * submission.contact_name will then be dropped + */ + +export async function up(knex: Knex): Promise { + return ( + Promise.resolve() + // Create public schema tables + .then(() => + knex.schema.createTable('enquiry', (table) => { + table.uuid('enquiry_id').primary(); + table + .text('activity_id') + .notNullable() + .references('activity_id') + .inTable('activity') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.uuid('assigned_user_id').references('user_id').inTable('user').onUpdate('CASCADE').onDelete('CASCADE'); + table.timestamp('submitted_at', { useTz: true }).notNullable(); + table.text('submitted_by').notNullable(); + table.text('contact_first_name'); + table.text('contact_last_name'); + table.text('contact_phone_number'); + table.text('contact_email'); + table.text('contact_preference'); + table.text('contact_applicant_relationship'); + table.text('is_related'); + table.text('related_activity_id'); + table.text('enquiry_description'); + table.text('apply_for_permit_connect'); + stamps(knex, table); + }) + ) + + .then(() => + knex.schema.alterTable('submission', (table) => { + table.text('contact_first_name'); + table.text('contact_last_name'); + }) + ) + + // Create public schema table triggers + .then(() => + knex.schema.raw(`create trigger before_update_enquiry_trigger + before update on "enquiry" + for each row execute procedure public.set_updated_at();`) + ) + + // Create audit triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_enquiry_trigger + AFTER UPDATE OR DELETE ON enquiry + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + // Drop audit triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_enquiry_trigger ON permit')) + + // Drop public schema table triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_enquiry_trigger ON "enquiry"')) + + // Revert table alters + .then(() => + knex.schema.alterTable('submission', function (table) { + table.dropColumn('contact_first_name'); + table.dropColumn('contact_last_name'); + }) + ) + + // Drop public schema tables + .then(() => knex.schema.dropTableIfExists('enquiry')) + ); +} diff --git a/app/src/db/models/enquiry.ts b/app/src/db/models/enquiry.ts new file mode 100644 index 00000000..ededb6d4 --- /dev/null +++ b/app/src/db/models/enquiry.ts @@ -0,0 +1,69 @@ +import { Prisma } from '@prisma/client'; + +import user from './user'; + +import type { Stamps } from '../stamps'; +import type { Enquiry } from '../../types'; + +// Define types +const _enquiry = Prisma.validator()({}); +const _enquiryWithGraph = Prisma.validator()({}); +const _enquiryWithUserGraph = Prisma.validator()({ include: { user: true } }); + +type PrismaRelationEnquiry = Omit, keyof Stamps>; +type PrismaGraphEnquiry = Prisma.enquiryGetPayload; +type PrismaGraphEnquiryUser = Prisma.enquiryGetPayload; + +export default { + toPrismaModel(input: Enquiry): PrismaRelationEnquiry { + return { + enquiry_id: input.enquiryId, + activity_id: input.activityId, + assigned_user_id: input.assignedUserId, + submitted_at: new Date(input.submittedAt ?? Date.now()), + submitted_by: input.submittedBy, + contact_first_name: input.contactFirstName, + contact_last_name: input.contactLastName, + contact_phone_number: input.contactPhoneNumber, + contact_email: input.contactEmail, + contact_preference: input.contactPreference, + contact_applicant_relationship: input.contactApplicantRelationship, + is_related: input.isRelated, + related_activity_id: input.relatedActivityId, + enquiry_description: input.enquiryDescription, + apply_for_permit_connect: input.applyForPermitConnect + }; + }, + + fromPrismaModel(input: PrismaGraphEnquiry): Enquiry { + return { + enquiryId: input.enquiry_id, + activityId: input.activity_id, + assignedUserId: input.assigned_user_id, + submittedAt: input.submitted_at?.toISOString() as string, + submittedBy: input.submitted_by, + contactFirstName: input.contact_first_name, + contactLastName: input.contact_last_name, + contactPhoneNumber: input.contact_phone_number, + contactEmail: input.contact_email, + contactPreference: input.contact_preference, + contactApplicantRelationship: input.contact_applicant_relationship, + isRelated: input.is_related, + relatedActivityId: input.related_activity_id, + enquiryDescription: input.enquiry_description, + applyForPermitConnect: input.apply_for_permit_connect, + user: null + }; + }, + + fromPrismaModelWithUser(input: PrismaGraphEnquiryUser | null): Enquiry | null { + if (!input) return null; + + const enquiry = this.fromPrismaModel(input); + if (enquiry && input.user) { + enquiry.user = user.fromPrismaModel(input.user); + } + + return enquiry; + } +}; diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index 37ed8d41..e62d513c 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,5 +1,6 @@ export { default as activity } from './activity'; export { default as document } from './document'; +export { default as enquiry } from './enquiry'; export { default as identity_provider } from './identity_provider'; export { default as note } from './note'; export { default as permit } from './permit'; diff --git a/app/src/db/models/submission.ts b/app/src/db/models/submission.ts index 1a0361cf..f7156073 100644 --- a/app/src/db/models/submission.ts +++ b/app/src/db/models/submission.ts @@ -72,7 +72,9 @@ export default { check_provincial_permits: input.checkProvincialPermits, indigenous_description: input.indigenousDescription, non_profit_description: input.nonProfitDescription, - housing_coop_description: input.housingCoopDescription + housing_coop_description: input.housingCoopDescription, + contact_first_name: input.contactFirstName, + contact_last_name: input.contactLastName }; }, @@ -134,6 +136,8 @@ export default { indigenousDescription: input.indigenous_description, nonProfitDescription: input.non_profit_description, housingCoopDescription: input.housing_coop_description, + contactFirstName: input.contact_first_name, + contactLastName: input.contact_last_name, user: null }; }, diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index dc1404e9..01145d10 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -28,6 +28,7 @@ model activity { updated_at DateTime? @db.Timestamptz(6) initiative initiative @relation(fields: [initiative_id], references: [initiative_id], onDelete: Cascade, map: "activity_initiative_id_foreign") document document[] + enquiry enquiry[] note note[] permit permit[] submission submission[] @@ -190,6 +191,8 @@ model submission { indigenous_description String? non_profit_description String? housing_coop_description String? + contact_first_name String? + contact_last_name String? activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "submission_activity_id_foreign") user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "submission_assigned_user_id_foreign") } @@ -208,6 +211,7 @@ model user { created_at DateTime? @default(now()) @db.Timestamptz(6) updated_by String? updated_at DateTime? @db.Timestamptz(6) + enquiry enquiry[] submission submission[] identity_provider identity_provider? @relation(fields: [idp], references: [idp], onDelete: Cascade, map: "user_idp_foreign") @@ -215,3 +219,27 @@ model user { @@index([identity_id], map: "user_identity_id_index") @@index([username], map: "user_username_index") } + +model enquiry { + enquiry_id String @id @db.Uuid + activity_id String + assigned_user_id String? @db.Uuid + submitted_at DateTime @db.Timestamptz(6) + submitted_by String + contact_first_name String? + contact_last_name String? + contact_phone_number String? + contact_email String? + contact_preference String? + contact_applicant_relationship String? + is_related String? + related_activity_id String? + enquiry_description String? + apply_for_permit_connect String? + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "enquiry_activity_id_foreign") + user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "enquiry_assigned_user_id_foreign") +} diff --git a/app/src/routes/v1/enquiry.ts b/app/src/routes/v1/enquiry.ts new file mode 100644 index 00000000..2c065ae4 --- /dev/null +++ b/app/src/routes/v1/enquiry.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { enquiryController } from '../../controllers'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; + +import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; + +const router = express.Router(); +router.use(requireSomeAuth); + +// Submission create draft endpoint +router.put('/draft', (req: Request, res: Response, next: NextFunction): void => { + enquiryController.createDraft(req, res, next); +}); + +// Submission update draft endpoint +router.put('/draft/:activityId', (req: Request, res: Response, next: NextFunction): void => { + enquiryController.updateDraft(req, res, next); +}); + +export default router; diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index b2dfd91f..9acb6b43 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 submission from './submission'; import document from './document'; +import enquiry from './enquiry'; import note from './note'; import permit from './permit'; import roadmap from './roadmap'; @@ -15,11 +16,12 @@ router.use(hasAccess); // Base v1 Responder router.get('/', (_req, res) => { res.status(200).json({ - endpoints: ['/document', '/note', '/permit', '/roadmap', '/submission', '/user'] + endpoints: ['/document', '/enquiry', '/note', '/permit', '/roadmap', '/submission', '/user'] }); }); router.use('/document', document); +router.use('/enquiry', enquiry); router.use('/note', note); router.use('/permit', permit); router.use('/roadmap', roadmap); diff --git a/app/src/services/enquiry.ts b/app/src/services/enquiry.ts new file mode 100644 index 00000000..576f9c70 --- /dev/null +++ b/app/src/services/enquiry.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-useless-catch */ +import prisma from '../db/dataConnection'; +import { enquiry } from '../db/models'; + +import type { Enquiry } from '../types'; + +const service = { + /** + * @function createEnquiry + * Creates a new enquiry + * @returns {Promise>} The result of running the transaction + */ + createEnquiry: async (data: Partial) => { + const response = await prisma.enquiry.create({ + data: enquiry.toPrismaModel(data as Enquiry) + }); + + return enquiry.fromPrismaModel(response); + }, + + /** + * @function updateEnquiry + * Updates a specific enquiry + * @param {Enquiry} data Enquiry to update + * @returns {Promise} The result of running the update operation + */ + updateEnquiry: async (data: Enquiry) => { + try { + const result = await prisma.enquiry.update({ + data: { ...enquiry.toPrismaModel(data), updated_by: data.updatedBy }, + where: { + enquiry_id: data.enquiryId + } + }); + + return enquiry.fromPrismaModel(result); + } catch (e: unknown) { + throw e; + } + } +}; + +export default service; diff --git a/app/src/services/index.ts b/app/src/services/index.ts index 93c10095..eeb8c24d 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -2,6 +2,7 @@ export { default as activityService } from './activity'; export { default as comsService } from './coms'; export { default as documentService } from './document'; export { default as emailService } from './email'; +export { default as enquiryService } from './enquiry'; export { default as noteService } from './note'; export { default as permitService } from './permit'; export { default as submissionService } from './submission'; diff --git a/app/src/types/Enquiry.ts b/app/src/types/Enquiry.ts new file mode 100644 index 00000000..5ad3c4a0 --- /dev/null +++ b/app/src/types/Enquiry.ts @@ -0,0 +1,22 @@ +import { IStamps } from '../interfaces/IStamps'; + +import type { User } from './User'; + +export type Enquiry = { + enquiryId: string; // Primary key + activityId: string; + assignedUserId: string | null; + submittedAt: string; + submittedBy: string; + contactFirstName: string | null; + contactLastName: string | null; + contactPhoneNumber: string | null; + contactEmail: string | null; + contactPreference: string | null; + contactApplicantRelationship: string | null; + isRelated: string | null; + relatedActivityId: string | null; + enquiryDescription: string | null; + applyForPermitConnect: string | null; + user: User | null; +} & Partial; diff --git a/app/src/types/Submission.ts b/app/src/types/Submission.ts index cc1051be..56753cf9 100644 --- a/app/src/types/Submission.ts +++ b/app/src/types/Submission.ts @@ -60,6 +60,8 @@ export type Submission = { indigenousDescription: string | null; nonProfitDescription: string | null; housingCoopDescription: string | null; + contactFirstName: string | null; + contactLastName: string | null; user: User | null; } & Partial; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 15c3c385..5bc3fa58 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -5,6 +5,7 @@ export type { ChefsSubmissionExport } from './ChefsSubmissionExport'; export type { CurrentUser } from './CurrentUser'; export type { Document } from './Document'; export type { Email } from './Email'; +export type { Enquiry } from './Enquiry'; export type { EmailAttachment } from './EmailAttachment'; export type { IdentityProvider } from './IdentityProvider'; export type { Note } from './Note'; diff --git a/frontend/src/components/intake/ShasEnquiryForm.vue b/frontend/src/components/intake/ShasEnquiryForm.vue new file mode 100644 index 00000000..077e9fdf --- /dev/null +++ b/frontend/src/components/intake/ShasEnquiryForm.vue @@ -0,0 +1,381 @@ + + +