diff --git a/app/src/components/constants.ts b/app/src/components/constants.ts index 35cb551b..5a827faa 100644 --- a/app/src/components/constants.ts +++ b/app/src/components/constants.ts @@ -14,6 +14,12 @@ export const DEFAULTCORS = Object.freeze({ origin: true }); +/** + * Basic + */ +export const YesNo = Object.freeze({ YES: 'Yes', NO: 'No' }); +export const YesNoUnsure = Object.freeze({ YES: 'Yes', NO: 'No', UNSURE: 'Unsure' }); + /** Current user authentication types */ export const IdentityProvider = Object.freeze({ IDIR: 'idir', @@ -34,7 +40,7 @@ export const Initiatives = Object.freeze({ HOUSING: 'HOUSING' }); -/** CHEFS form statuses */ +/** SHAS form statuses */ export const APPLICATION_STATUS_LIST = Object.freeze({ NEW: 'New', IN_PROGRESS: 'In Progress', @@ -45,13 +51,20 @@ export const APPLICATION_STATUS_LIST = Object.freeze({ export const INTAKE_STATUS_LIST = Object.freeze({ SUBMITTED: 'Submitted', ASSIGNED: 'Assigned', - COMPLETED: 'Completed' + COMPLETED: 'Completed', + DRAFT: 'Draft' }); -export const RENTAL_STATUS_LIST = Object.freeze({ +export const PERMIT_NEEDED = Object.freeze({ YES: 'Yes', - NO: 'No', - UNSURE: 'Unsure' + UNDER_INVESTIGATION: 'Under investigation', + NO: 'No' +}); + +export const PERMIT_STATUS = Object.freeze({ + NEW: 'New', + APPLIED: 'Applied', + COMPLETED: 'Completed' }); /** Types of notes */ diff --git a/app/src/components/utils.ts b/app/src/components/utils.ts index 588051ff..667cb4d2 100644 --- a/app/src/components/utils.ts +++ b/app/src/components/utils.ts @@ -227,5 +227,17 @@ export function redactSecrets(data: { [key: string]: unknown }, fields: Array(cfg).map(async (x: ChefsFormConfigData) => { return (await submissionService.getFormExport(x.id)).map((data: ChefsSubmissionExport) => { const financiallySupportedValues = { - financiallySupportedBC: isTruthy(data.isBCHousingSupported), - financiallySupportedIndigenous: isTruthy(data.isIndigenousHousingProviderSupported), - financiallySupportedNonProfit: isTruthy(data.isNonProfitSupported), - financiallySupportedHousingCoop: isTruthy(data.isHousingCooperativeSupported) + financiallySupportedBC: data.isBCHousingSupported ? toTitleCase(data.isBCHousingSupported) : YesNo.NO, + financiallySupportedIndigenous: data.isIndigenousHousingProviderSupported + ? toTitleCase(data.isIndigenousHousingProviderSupported) + : YesNo.NO, + financiallySupportedNonProfit: data.isNonProfitSupported + ? toTitleCase(data.isNonProfitSupported) + : YesNo.NO, + financiallySupportedHousingCoop: data.isHousingCooperativeSupported + ? toTitleCase(data.isHousingCooperativeSupported) + : YesNo.NO }; // Get greatest of multiple Units data @@ -117,7 +131,7 @@ const controller = { contactPhoneNumber: data.contactPhoneNumber, contactName: `${data.contactFirstName} ${data.contactLastName}`, contactApplicantRelationship: camelCaseToTitleCase(data.contactApplicantRelationship), - financiallySupported: Object.values(financiallySupportedValues).includes(true), + financiallySupported: Object.values(financiallySupportedValues).includes(YesNo.YES), ...financiallySupportedValues, intakeStatus: toTitleCase(data.form.status), locationPIDs: data.parcelID, @@ -126,9 +140,9 @@ const controller = { naturalDisaster: data.naturalDisasterInd, queuePriority: parseInt(data.queuePriority), singleFamilyUnits: maxUnits, - isRentalUnit: data.isRentalUnit + hasRentalUnits: data.isRentalUnit ? camelCaseToTitleCase(deDupeUnsure(data.isRentalUnit)) - : RENTAL_STATUS_LIST.UNSURE, + : YesNoUnsure.UNSURE, streetAddress: data.streetAddress, submittedAt: data.form.createdAt, submittedBy: data.form.username, @@ -153,23 +167,123 @@ const controller = { notStored.map((x) => x.permits?.map(async (y) => await permitService.createPermit(y))); }, - createEmptySubmission: async (req: Request, res: Response, next: NextFunction) => { - let testSubmissionId; - let submissionQuery; - - // Testing for activityId collisions, which are truncated UUIDs - // If a collision is detected, generate new UUID and test again - do { - testSubmissionId = uuidv4(); - submissionQuery = await submissionService.getSubmission(testSubmissionId.substring(0, 8).toUpperCase()); - } while (submissionQuery); - + createSubmission: async (req: Request, res: Response, next: NextFunction) => { try { + const newActivityId = await generateUniqueActivityId(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any - const submitter = (req.currentUser?.tokenPayload as any)?.idir_username; - const result = await submissionService.createEmptySubmission(testSubmissionId, submitter); + const data: any = req.body; + + let applicant, basic, housing, location, permits; + let appliedPermits: Array = [], + investigatePermits: Array = []; + + // Create applicant information + if (data.applicant) { + applicant = { + contactName: `${data.applicant.firstName} ${data.applicant.lastName}`, + contactPhoneNumber: data.applicant.phoneNumber, + contactEmail: data.applicant.email, + contactApplicantRelationship: data.applicant.relationshipToProject, + contactPreference: data.applicant.contactPreference + }; + } + + if (data.basic) { + basic = { + isDevelopedByCompanyOrOrg: data.basic.isDevelopedByCompanyOrOrg, + isDevelopedInBC: data.basic.isDevelopedInBC, + companyNameRegistered: data.basic.registeredName + }; + } + + if (data.housing) { + housing = { + projectName: data.housing.projectName, + projectDescription: data.housing.projectDescription, + //singleFamilySelected: true, // not necessary to save - check if singleFamilyUnits not null + //multiFamilySelected: true, // not necessary to save - check if multiFamilyUnits not null + singleFamilyUnits: data.housing.singleFamilyUnits, + multiFamilyUnits: data.housing.multiFamilyUnits, + //otherSelected: true, // not necessary to save - check if otherUnits not null + otherUnitsDescription: data.housing.otherUnitsDescription, + otherUnits: data.housing.otherUnits, + hasRentalUnits: data.housing.hasRentalUnits, + financiallySupportedBC: data.housing.financiallySupportedBC, + financiallySupportedIndigenous: data.housing.financiallySupportedIndigenous, + financiallySupportedNonProfit: data.housing.financiallySupportedNonProfit, + financiallySupportedHousingCoop: data.housing.financiallySupportedHousingCoop, + rentalUnits: data.housing.rentalUnits, + indigenousDescription: data.housing.indigenousDescription, + nonProfitDescription: data.housing.nonProfitDescription, + housingCoopDescription: data.housing.housingCoopDescription + }; + } + + if (data.location) { + location = { + naturalDisaster: data.location.naturalDisaster, + projectLocation: data.location.projectLocation, + locationPIDs: data.location.ltsaPIDLookup, + latitude: data.location.latitude, + longitude: data.location.longitude, + //addressSearch: 'Search address', // not necessary to save - client side search field + streetAddress: data.location.streetAddress, + locality: data.location.locality, + province: data.location.province + }; + } + + if (data.permits) { + permits = { + hasAppliedProvincialPermits: data.permits.hasAppliedProvincialPermits, + checkProvincialPermits: data.permits.checkProvincialPermits + }; + } + + if (data.appliedPermits && data.appliedPermits.length) { + appliedPermits = data.appliedPermits.map((x: Permit) => ({ + permitTypeId: x.permitTypeId, + activityId: newActivityId, + trackingId: x.trackingId, + status: PERMIT_STATUS.APPLIED, + statusLastVerified: x.statusLastVerified + })); + } + + if (data.investigatePermits && data.investigatePermits.length) { + investigatePermits = data.investigatePermits.flatMap((x: Permit) => ({ + permitTypeId: x.permitTypeId, + activityId: newActivityId, + needed: PERMIT_NEEDED.UNDER_INVESTIGATION, + statusLastVerified: x.statusLastVerified + })); + } + + // Put new submission together + const submission = { + ...applicant, + ...basic, + ...housing, + ...location, + ...permits, + submissionId: uuidv4(), + activityId: newActivityId, + submittedAt: new Date().toISOString(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + submittedBy: (req.currentUser?.tokenPayload as any)?.idir_username, + intakeStatus: INTAKE_STATUS_LIST.SUBMITTED, + applicationStatus: APPLICATION_STATUS_LIST.NEW + }; + + // Create new submission + const result = await submissionService.createSubmission(submission); + + // Create each permit + await Promise.all(appliedPermits.map(async (x: Permit) => await permitService.createPermit(x))); + await Promise.all(investigatePermits.map(async (x: Permit) => await permitService.createPermit(x))); - res.status(201).json({ activityId: result.activity_id }); + res.status(201).json({ activityId: result.activityId }); } catch (e: unknown) { next(e); } diff --git a/app/src/db/migrations/20240402000000_002-submission-updates.ts b/app/src/db/migrations/20240402000000_002-submission-updates.ts index 7ac596d4..1e2f1ced 100644 --- a/app/src/db/migrations/20240402000000_002-submission-updates.ts +++ b/app/src/db/migrations/20240402000000_002-submission-updates.ts @@ -1,4 +1,4 @@ -import { RENTAL_STATUS_LIST } from '../../components/constants'; +import { YesNoUnsure } from '../../components/constants'; import type { Knex } from 'knex'; export async function up(knex: Knex): Promise { @@ -6,7 +6,7 @@ export async function up(knex: Knex): Promise { knex.schema.alterTable('submission', function (table) { table.text('contact_preference'); table.text('contact_applicant_relationship'); - table.text('is_rental_unit').notNullable().defaultTo(RENTAL_STATUS_LIST.UNSURE); + table.text('is_rental_unit').notNullable().defaultTo(YesNoUnsure.UNSURE); table.text('project_description'); }) ); diff --git a/app/src/db/migrations/20240506000000_004-shas-intake.ts b/app/src/db/migrations/20240506000000_004-shas-intake.ts new file mode 100644 index 00000000..33ef749b --- /dev/null +++ b/app/src/db/migrations/20240506000000_004-shas-intake.ts @@ -0,0 +1,195 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return Promise.resolve() + .then(() => + knex.schema.alterTable('submission', function (table) { + table.text('is_developed_by_company_or_org'); + table.text('is_developed_in_bc'); + table.text('multi_family_units'); + table.text('other_units'); + table.text('other_units_description'); + table.text('rental_units'); + table.text('project_location'); + table.text('locality'); + table.text('province'); + table.text('has_applied_provincial_permits'); + table.text('check_provincial_permits'); + table.text('indigenous_description'); + table.text('non_profit_description'); + table.text('housing_coop_description'); + table.renameColumn('is_rental_unit', 'has_rental_units'); + table.setNullable('has_rental_units'); + }) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + alter column has_rental_units drop default;`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + alter column financially_supported_bc drop default, + alter column financially_supported_bc drop not null, + alter column financially_supported_bc set data type text + using case + when financially_supported_bc = true then 'Yes' + when financially_supported_bc = false then 'No' + end, + add constraint submission_financially_supported_bc_check check ( + case + when intake_status <> 'Draft' then financially_supported_bc in ('Yes', 'No', 'Unsure') + else true + end + );`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + alter column financially_supported_indigenous drop default, + alter column financially_supported_indigenous drop not null, + alter column financially_supported_indigenous set data type text + using case + when financially_supported_indigenous = true then 'Yes' + when financially_supported_indigenous = false then 'No' + end, + add constraint submission_financially_supported_indigenous_check check ( + case + when intake_status <> 'Draft' then financially_supported_indigenous in ('Yes', 'No', 'Unsure') + else true + end + );`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + alter column financially_supported_non_profit drop default, + alter column financially_supported_non_profit drop not null, + alter column financially_supported_non_profit set data type text + using case + when financially_supported_non_profit = true then 'Yes' + when financially_supported_non_profit = false then 'No' + end, + add constraint submission_financially_supported_non_profit_check check ( + case + when intake_status <> 'Draft' then financially_supported_non_profit in ('Yes', 'No', 'Unsure') + else true + end + );`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + alter column financially_supported_housing_coop drop default, + alter column financially_supported_housing_coop drop not null, + alter column financially_supported_housing_coop set data type text + using case + when financially_supported_housing_coop = true then 'Yes' + when financially_supported_housing_coop = false then 'No' + end, + add constraint submission_financially_supported_housing_coop_check check ( + case + when intake_status <> 'Draft' then financially_supported_housing_coop in ('Yes', 'No', 'Unsure') + else true + end + );`) + ) + + .then(() => + knex.schema.alterTable('permit', function (table) { + table.timestamp('status_last_verified', { useTz: true }); + }) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + .then(() => + knex.schema.alterTable('permit', function (table) { + table.dropColumn('status_last_verified'); + }) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + drop constraint submission_financially_supported_housing_coop_check, + alter column financially_supported_housing_coop set data type boolean + using case + when financially_supported_housing_coop = 'Yes' then true + else false + end, + alter column financially_supported_housing_coop set not null, + alter column financially_supported_housing_coop set default false;`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + drop constraint submission_financially_supported_non_profit_check, + alter column financially_supported_non_profit set data type boolean + using case + when financially_supported_non_profit = 'Yes' then true + else false + end, + alter column financially_supported_non_profit set not null, + alter column financially_supported_non_profit set default false;`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + drop constraint submission_financially_supported_indigenous_check, + alter column financially_supported_indigenous set data type boolean + using case + when financially_supported_indigenous = 'Yes' then true + else false + end, + alter column financially_supported_indigenous set not null, + alter column financially_supported_indigenous set default false;`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + drop constraint submission_financially_supported_bc_check, + alter column financially_supported_bc set data type boolean + using case + when financially_supported_bc = 'Yes' then true + else false + end, + alter column financially_supported_bc set not null, + alter column financially_supported_bc set default false;`) + ) + + .then(() => + knex.schema.raw(`alter table public.submission + alter column has_rental_units set default 'Unsure'::text;`) + ) + + // Have to set nulls to default before dropping nullable + .then(() => + knex.schema.raw(`update public.submission + set has_rental_units = 'Unsure' where has_rental_units is null;`) + ) + + .then(() => + knex.schema.alterTable('submission', function (table) { + table.dropNullable('has_rental_units'); + table.renameColumn('has_rental_units', 'is_rental_unit'); + table.dropColumn('housing_coop_description'); + table.dropColumn('non_profit_description'); + table.dropColumn('indigenous_description'); + table.dropColumn('check_provincial_permits'); + table.dropColumn('has_applied_provincial_permits'); + table.dropColumn('province'); + table.dropColumn('locality'); + table.dropColumn('project_location'); + table.dropColumn('rental_units'); + table.dropColumn('other_units_description'); + table.dropColumn('other_units'); + table.dropColumn('multi_family_units'); + table.dropColumn('is_developed_in_bc'); + table.dropColumn('is_developed_by_company_or_org'); + }) + ) + ); +} diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index 6e05e8b9..37ed8d41 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,3 +1,4 @@ +export { default as activity } from './activity'; export { default as document } from './document'; export { default as identity_provider } from './identity_provider'; export { default as note } from './note'; diff --git a/app/src/db/models/permit.ts b/app/src/db/models/permit.ts index 913705b2..96efd00d 100644 --- a/app/src/db/models/permit.ts +++ b/app/src/db/models/permit.ts @@ -24,7 +24,8 @@ export default { needed: input.needed, status: input.status, submitted_date: input.submittedDate ? new Date(input.submittedDate) : null, - adjudication_date: input.adjudicationDate ? new Date(input.adjudicationDate) : null + adjudication_date: input.adjudicationDate ? new Date(input.adjudicationDate) : null, + status_last_verified: input.statusLastVerified ? new Date(input.statusLastVerified) : null }; }, @@ -42,6 +43,7 @@ export default { status: input.status, submittedDate: input.submitted_date?.toISOString() ?? null, adjudicationDate: input.adjudication_date?.toISOString() ?? null, + statusLastVerified: input.status_last_verified?.toISOString() ?? null, updatedAt: input.updated_at?.toISOString() ?? null, updatedBy: input.updated_by }; diff --git a/app/src/db/models/submission.ts b/app/src/db/models/submission.ts index 6753599c..1a0361cf 100644 --- a/app/src/db/models/submission.ts +++ b/app/src/db/models/submission.ts @@ -32,7 +32,7 @@ export default { contact_preference: input.contactPreference, company_name_registered: input.companyNameRegistered, single_family_units: input.singleFamilyUnits, - is_rental_unit: input.isRentalUnit, + has_rental_units: input.hasRentalUnits, street_address: input.streetAddress, latitude: input.latitude ? new Decimal(input.latitude) : null, longitude: input.longitude ? new Decimal(input.longitude) : null, @@ -58,7 +58,21 @@ export default { status_request: input.statusRequest, inquiry: input.inquiry, emergency_assist: input.emergencyAssist, - inapplicable: input.inapplicable + inapplicable: input.inapplicable, + is_developed_by_company_or_org: input.isDevelopedByCompanyOrOrg, + is_developed_in_bc: input.isDevelopedInBC, + multi_family_units: input.multiFamilyUnits, + other_units: input.otherUnits, + other_units_description: input.otherUnitsDescription, + rental_units: input.rentalUnits, + project_location: input.projectLocation, + locality: input.locality, + province: input.province, + has_applied_provincial_permits: input.hasAppliedProvincialPermits, + check_provincial_permits: input.checkProvincialPermits, + indigenous_description: input.indigenousDescription, + non_profit_description: input.nonProfitDescription, + housing_coop_description: input.housingCoopDescription }; }, @@ -79,7 +93,7 @@ export default { projectDescription: input.project_description, companyNameRegistered: input.company_name_registered, singleFamilyUnits: input.single_family_units, - isRentalUnit: input.is_rental_unit, + hasRentalUnits: input.has_rental_units, streetAddress: input.street_address, latitude: input.latitude ? input.latitude.toNumber() : null, longitude: input.longitude ? input.longitude.toNumber() : null, @@ -106,6 +120,20 @@ export default { inquiry: input.inquiry, emergencyAssist: input.emergency_assist, inapplicable: input.inapplicable, + isDevelopedByCompanyOrOrg: input.is_developed_by_company_or_org, + isDevelopedInBC: input.is_developed_in_bc, + multiFamilyUnits: input.multi_family_units, + otherUnits: input.other_units, + otherUnitsDescription: input.other_units_description, + rentalUnits: input.rental_units, + projectLocation: input.project_location, + locality: input.locality, + province: input.province, + hasAppliedProvincialPermits: input.has_applied_provincial_permits, + checkProvincialPermits: input.check_provincial_permits, + indigenousDescription: input.indigenous_description, + nonProfitDescription: input.non_profit_description, + housingCoopDescription: input.housing_coop_description, user: null }; }, diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index c7f163cf..6b7c7dd7 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -86,22 +86,23 @@ model note { } model permit { - permit_id String @id @db.Uuid - permit_type_id Int - activity_id String - issued_permit_id String? - tracking_id String? - auth_status String? - needed String? - status String? - submitted_date DateTime? @db.Timestamptz(6) - adjudication_date DateTime? @db.Timestamptz(6) - 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: "permit_activity_id_foreign") - permit_type permit_type @relation(fields: [permit_type_id], references: [permit_type_id], onDelete: Cascade, map: "permit_permit_type_id_foreign") + permit_id String @id @db.Uuid + permit_type_id Int + activity_id String + issued_permit_id String? + tracking_id String? + auth_status String? + needed String? + status String? + submitted_date DateTime? @db.Timestamptz(6) + adjudication_date DateTime? @db.Timestamptz(6) + 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) + status_last_verified DateTime? @db.Timestamptz(6) + activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "permit_activity_id_foreign") + permit_type permit_type @relation(fields: [permit_type_id], references: [permit_type_id], onDelete: Cascade, map: "permit_permit_type_id_foreign") @@unique([permit_id, permit_type_id, activity_id], map: "permit_permit_id_permit_type_id_activity_id_unique") } @@ -127,6 +128,7 @@ model permit_type { permit permit[] } +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. model submission { submission_id String @id @db.Uuid activity_id String @@ -153,10 +155,10 @@ model submission { bc_online_completed Boolean @default(false) natural_disaster Boolean @default(false) financially_supported Boolean @default(false) - financially_supported_bc Boolean @default(false) - financially_supported_indigenous Boolean @default(false) - financially_supported_non_profit Boolean @default(false) - financially_supported_housing_coop Boolean @default(false) + financially_supported_bc String? + financially_supported_indigenous String? + financially_supported_non_profit String? + financially_supported_housing_coop String? aai_updated Boolean @default(false) waiting_on String? intake_status String? @@ -172,8 +174,22 @@ model submission { updated_at DateTime? @db.Timestamptz(6) contact_preference String? contact_applicant_relationship String? - is_rental_unit String @default("Unsure") + has_rental_units String? project_description String? + is_developed_by_company_or_org String? + is_developed_in_bc String? + multi_family_units String? + other_units String? + other_units_description String? + rental_units String? + project_location String? + locality String? + province String? + has_applied_provincial_permits String? + check_provincial_permits String? + indigenous_description String? + non_profit_description String? + housing_coop_description 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") } @@ -199,3 +215,9 @@ model user { @@index([identity_id], map: "user_identity_id_index") @@index([username], map: "user_username_index") } + +enum yes_no_unsure { + Yes + No + Unsure +} diff --git a/app/src/db/utils/utils.ts b/app/src/db/utils/utils.ts new file mode 100644 index 00000000..18f03a41 --- /dev/null +++ b/app/src/db/utils/utils.ts @@ -0,0 +1,20 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { activityService } from '../../services'; +import { uuidToActivityId } from '../../components/utils'; +/** + * @function generateUniqueActivityId + * Generate a new activityId, which are truncated UUIDs + * If a collision is detected, generate new UUID and test again + * @returns {Promise} A string in title case + */ +export async function generateUniqueActivityId() { + let id, queryResult; + + do { + id = uuidToActivityId(uuidv4()); + queryResult = await activityService.getActivity(id); + } while (queryResult); + + return id; +} diff --git a/app/src/routes/v1/submission.ts b/app/src/routes/v1/submission.ts index d50d3f98..0f1d011a 100644 --- a/app/src/routes/v1/submission.ts +++ b/app/src/routes/v1/submission.ts @@ -22,9 +22,9 @@ router.get( } ); -// Submission endpoint -router.put('/create', (req: Request, res: Response, next: NextFunction): void => { - submissionController.createEmptySubmission(req, res, next); +// Submission create endpoint +router.put('/', (req: Request, res: Response, next: NextFunction): void => { + submissionController.createSubmission(req, res, next); }); router.get( diff --git a/app/src/services/activity.ts b/app/src/services/activity.ts new file mode 100644 index 00000000..17cb037f --- /dev/null +++ b/app/src/services/activity.ts @@ -0,0 +1,22 @@ +import prisma from '../db/dataConnection'; +import { activity } from '../db/models'; + +const service = { + /** + * @function getActivity + * Get an activity + * @param {string} activityId Unique activity ID + * @returns {Promise} The result of running the findFirst operation + */ + getActivity: async (activityId: string) => { + const response = await prisma.activity.findFirst({ + where: { + activity_id: activityId + } + }); + + return activity.fromPrismaModel(response); + } +}; + +export default service; diff --git a/app/src/services/index.ts b/app/src/services/index.ts index d0dbdf27..93c10095 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1,3 +1,4 @@ +export { default as activityService } from './activity'; export { default as comsService } from './coms'; export { default as documentService } from './document'; export { default as emailService } from './email'; diff --git a/app/src/services/submission.ts b/app/src/services/submission.ts index 191523f8..e00aae0f 100644 --- a/app/src/services/submission.ts +++ b/app/src/services/submission.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import config from 'config'; -import { APPLICATION_STATUS_LIST, INTAKE_STATUS_LIST, Initiatives } from '../components/constants'; +import { APPLICATION_STATUS_LIST, Initiatives } from '../components/constants'; import { getChefsApiKey } from '../components/utils'; import prisma from '../db/dataConnection'; import { submission } from '../db/models'; @@ -27,37 +27,31 @@ function chefsAxios(formId: string, options: AxiosRequestConfig = {}): AxiosInst const service = { /** - * @function createEmptySubmission - * Creates a new minimal submission - * @returns {Promise>} The result of running the transaction + * @function createSubmission + * Creates a new submission + * @returns {Promise>} The result of running the transaction */ - createEmptySubmission: async (submissionId: string, createdBy: string): Promise> => { - return await prisma.$transaction(async (trx) => { + createSubmission: async (data: Partial) => { + const response = await prisma.$transaction(async (trx) => { const initiative = await trx.initiative.findFirstOrThrow({ where: { code: Initiatives.HOUSING } }); - const activityId = submissionId.substring(0, 8).toUpperCase() as string; await trx.activity.create({ data: { - activity_id: activityId, + activity_id: data.activityId as string, initiative_id: initiative.initiative_id } }); return await trx.submission.create({ - data: { - submission_id: submissionId, - activity_id: activityId, - application_status: APPLICATION_STATUS_LIST.NEW, - intake_status: INTAKE_STATUS_LIST.SUBMITTED, - submitted_at: new Date(Date.now()), - submitted_by: createdBy - } + data: submission.toPrismaModel(data as Submission) }); }); + + return submission.fromPrismaModel(response); }, /** @@ -106,7 +100,7 @@ const service = { project_description: x.projectDescription, queue_priority: x.queuePriority, single_family_units: x.singleFamilyUnits, - is_rental_unit: x.isRentalUnit, + has_rental_units: x.hasRentalUnits, street_address: x.streetAddress, submitted_at: new Date(x.submittedAt ?? Date.now()), submitted_by: x.submittedBy as string diff --git a/app/src/types/ChefsSubmissionExport.ts b/app/src/types/ChefsSubmissionExport.ts index c1f1ad6c..4dba41d1 100644 --- a/app/src/types/ChefsSubmissionExport.ts +++ b/app/src/types/ChefsSubmissionExport.ts @@ -26,10 +26,10 @@ export type ChefsSubmissionExport = { contactApplicantRelationship: string; financiallySupported: boolean; intakeStatus: string; - isBCHousingSupported: boolean; - isIndigenousHousingProviderSupported: boolean; - isNonProfitSupported: boolean; - isHousingCooperativeSupported: boolean; + isBCHousingSupported: string; + isIndigenousHousingProviderSupported: string; + isNonProfitSupported: string; + isHousingCooperativeSupported: string; parcelID: string; latitude: number; longitude: number; diff --git a/app/src/types/Permit.ts b/app/src/types/Permit.ts index dc6906bc..b5fed0b4 100644 --- a/app/src/types/Permit.ts +++ b/app/src/types/Permit.ts @@ -11,4 +11,5 @@ export type Permit = { status: string | null; submittedDate: string | null; adjudicationDate: string | null; + statusLastVerified: string | null; } & Partial; diff --git a/app/src/types/Submission.ts b/app/src/types/Submission.ts index 6bc689ef..cc1051be 100644 --- a/app/src/types/Submission.ts +++ b/app/src/types/Submission.ts @@ -18,7 +18,7 @@ export type Submission = { projectName: string | null; projectDescription: string | null; singleFamilyUnits: string | null; - isRentalUnit: string; + hasRentalUnits: string | null; streetAddress: string | null; latitude: number | null; longitude: number | null; @@ -32,10 +32,10 @@ export type Submission = { bcOnlineCompleted: boolean; naturalDisaster: boolean; financiallySupported: boolean; - financiallySupportedBC: boolean; - financiallySupportedIndigenous: boolean; - financiallySupportedNonProfit: boolean; - financiallySupportedHousingCoop: boolean; + financiallySupportedBC: string | null; + financiallySupportedIndigenous: string | null; + financiallySupportedNonProfit: string | null; + financiallySupportedHousingCoop: string | null; aaiUpdated: boolean; waitingOn: string | null; intakeStatus: string | null; @@ -45,5 +45,21 @@ export type Submission = { inquiry: boolean; emergencyAssist: boolean; inapplicable: boolean; + + isDevelopedByCompanyOrOrg: string | null; + isDevelopedInBC: string | null; + multiFamilyUnits: string | null; + otherUnits: string | null; + otherUnitsDescription: string | null; + rentalUnits: string | null; + projectLocation: string | null; + locality: string | null; + province: string | null; + hasAppliedProvincialPermits: string | null; + checkProvincialPermits: string | null; + indigenousDescription: string | null; + nonProfitDescription: string | null; + housingCoopDescription: string | null; + user: User | null; } & Partial; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 883defb8..15c3c385 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -1,3 +1,4 @@ +export type { Activity } from './Activity'; export type { BringForward } from './BringForward'; export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionExport } from './ChefsSubmissionExport'; diff --git a/app/src/validators/submission.ts b/app/src/validators/submission.ts index c525a948..a4a84e9b 100644 --- a/app/src/validators/submission.ts +++ b/app/src/validators/submission.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; -import { RENTAL_STATUS_LIST } from '../components/constants'; +import { YesNoUnsure } from '../components/constants'; import { activityId, emailJoi, uuidv4 } from './common'; import { validate } from '../middleware/validation'; @@ -36,7 +36,7 @@ const schema = { projectDescription: Joi.string().min(0).allow(null), companyNameRegistered: Joi.string().min(0).max(255).allow(null), singleFamilyUnits: Joi.string().min(0).max(255).allow(null), - isRentalUnit: Joi.string().valid(...Object.values(RENTAL_STATUS_LIST)), + isRentalUnit: Joi.string().valid(...Object.values(YesNoUnsure)), streetAddress: Joi.string().min(0).max(255).allow(null), latitude: Joi.number().max(255).allow(null), longitude: Joi.number().max(255).allow(null), diff --git a/app/tests/unit/controllers/permit.spec.ts b/app/tests/unit/controllers/permit.spec.ts index c1ed3e22..64ed549d 100644 --- a/app/tests/unit/controllers/permit.spec.ts +++ b/app/tests/unit/controllers/permit.spec.ts @@ -65,7 +65,8 @@ describe('createPermit', () => { needed: 'true', status: 'FOO', submittedDate: now.toISOString(), - adjudicationDate: now.toISOString() + adjudicationDate: now.toISOString(), + statusLastVerified: now.toISOString() }; createSpy.mockResolvedValue(created); @@ -144,7 +145,8 @@ describe('deletePermit', () => { needed: 'true', status: 'FOO', submittedDate: now.toISOString(), - adjudicationDate: now.toISOString() + adjudicationDate: now.toISOString(), + statusLastVerified: now.toISOString() }; deleteSpy.mockResolvedValue(deleted); @@ -261,7 +263,8 @@ describe('listPermits', () => { needed: 'true', status: 'FOO', submittedDate: now.toISOString(), - adjudicationDate: now.toISOString() + adjudicationDate: now.toISOString(), + statusLastVerified: now.toISOString() } ]; @@ -335,7 +338,8 @@ describe('updatePermit', () => { needed: 'true', status: 'FOO', submittedDate: now.toISOString(), - adjudicationDate: now.toISOString() + adjudicationDate: now.toISOString(), + statusLastVerified: now.toISOString() }; updateSpy.mockResolvedValue(updated); diff --git a/app/tests/unit/controllers/submission.spec.ts b/app/tests/unit/controllers/submission.spec.ts index c8f973cd..0f8bb872 100644 --- a/app/tests/unit/controllers/submission.spec.ts +++ b/app/tests/unit/controllers/submission.spec.ts @@ -1,9 +1,9 @@ import config from 'config'; import { NIL } from 'uuid'; -import { APPLICATION_STATUS_LIST } from '../../../src/components/constants'; +import { APPLICATION_STATUS_LIST, INTAKE_STATUS_LIST } from '../../../src/components/constants'; import submissionController from '../../../src/controllers/submission'; -import { permitService, submissionService, userService } from '../../../src/services'; +import { activityService, permitService, submissionService, userService } from '../../../src/services'; import * as utils from '../../../src/components/utils'; import type { Permit, Submission } from '../../../src/types'; @@ -55,10 +55,10 @@ const FORM_EXPORT_1 = { contactApplicantRelationship: 'Agent', financiallySupported: true, intakeStatus: 'IN PROGRESS', - isBCHousingSupported: true, - isIndigenousHousingProviderSupported: true, - isNonProfitSupported: true, - isHousingCooperativeSupported: true, + isBCHousingSupported: 'Yes', + isIndigenousHousingProviderSupported: 'Yes', + isNonProfitSupported: 'Yes', + isHousingCooperativeSupported: 'Yes', parcelID: '132', latitude: -48, longitude: 160, @@ -104,10 +104,10 @@ const FORM_EXPORT_2 = { financiallySupported: true, intakeStatus: 'IN PROGRESS', - isBCHousingSupported: true, - isIndigenousHousingProviderSupported: true, - isNonProfitSupported: true, - isHousingCooperativeSupported: true, + isBCHousingSupported: 'Yes', + isIndigenousHousingProviderSupported: 'Yes', + isNonProfitSupported: 'Yes', + isHousingCooperativeSupported: 'Yes', parcelID: '132', latitude: -59, longitude: 178, @@ -147,10 +147,10 @@ const FORM_SUBMISSION_1: Partial500', - isRentalUnit: 'Yes', + hasRentalUnits: 'Yes', streetAddress: '112 Other Road', submittedAt: FORM_EXPORT_2.form.createdAt, submittedBy: 'USERABC', @@ -248,10 +248,10 @@ const SUBMISSION_1 = { bcOnlineCompleted: true, naturalDisaster: false, financiallySupported: true, - financiallySupportedBC: true, - financiallySupportedIndigenous: false, - financiallySupportedNonProfit: false, - financiallySupportedHousingCoop: false, + financiallySupportedBC: 'Yes', + financiallySupportedIndigenous: 'Yes', + financiallySupportedNonProfit: 'Yes', + financiallySupportedHousingCoop: 'Yes', aaiUpdated: true, waitingOn: null, bringForwardDate: null, @@ -273,8 +273,6 @@ describe('checkAndStoreNewSubmissions', () => { const formExportSpy = jest.spyOn(submissionService, 'getFormExport'); const searchSubmissionsSpy = jest.spyOn(submissionService, 'searchSubmissions'); const createSubmissionsFromExportSpy = jest.spyOn(submissionService, 'createSubmissionsFromExport'); - const createEmptySubmissionSpy = jest.spyOn(submissionService, 'createEmptySubmission'); - const getSubmissionSpy = jest.spyOn(submissionService, 'getSubmission'); it('creates submissions', async () => { (config.get as jest.Mock).mockReturnValueOnce({ @@ -317,7 +315,8 @@ describe('checkAndStoreNewSubmissions', () => { permitTypesSpy.mockResolvedValue(PERMIT_TYPES); formExportSpy.mockResolvedValueOnce([FORM_EXPORT_1, FORM_EXPORT_2]).mockResolvedValueOnce([]); - searchSubmissionsSpy.mockResolvedValue([SUBMISSION_1]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + searchSubmissionsSpy.mockResolvedValue([SUBMISSION_1 as any]); createSubmissionsFromExportSpy.mockResolvedValue(); createPermitSpy.mockResolvedValue(null); @@ -344,42 +343,6 @@ describe('checkAndStoreNewSubmissions', () => { expect(createPermitSpy).toHaveBeenCalledTimes(1); }); - it('creates empty submission with no UUID collision', async () => { - const req = { - body: SUBMISSION_1, - currentUser: CURRENT_USER - }; - const next = jest.fn(); - - createEmptySubmissionSpy.mockResolvedValue({ activity_id: '00000000' }); - getSubmissionSpy.mockResolvedValue(null); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.createEmptySubmission(req as any, res as any, next); - - expect(createEmptySubmissionSpy).toHaveBeenCalledTimes(1); - expect(getSubmissionSpy).toHaveBeenCalledTimes(1); - }); - - it('creates empty submission with a UUID collision', async () => { - const req = { - body: SUBMISSION_1, - currentUser: CURRENT_USER - }; - const next = jest.fn(); - - createEmptySubmissionSpy.mockResolvedValue({ activity_id: '11112222' }); - // Mocking two UUID collisions - getSubmissionSpy.mockResolvedValueOnce(SUBMISSION_1); - getSubmissionSpy.mockResolvedValueOnce(SUBMISSION_1); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.createEmptySubmission(req as any, res as any, next); - - expect(createEmptySubmissionSpy).toHaveBeenCalledTimes(1); - expect(getSubmissionSpy).toHaveBeenCalledTimes(3); - }); - it('creates permits', async () => { (config.get as jest.Mock).mockReturnValueOnce({ form1: { @@ -413,6 +376,189 @@ describe('checkAndStoreNewSubmissions', () => { }); }); +describe('createSubmission', () => { + // Mock service calls + const createPermitSpy = jest.spyOn(permitService, 'createPermit'); + const createSubmissionSpy = jest.spyOn(submissionService, 'createSubmission'); + const getActivitySpy = jest.spyOn(activityService, 'getActivity'); + + it('should return 201 and new activity ID if all good', async () => { + const req = { + body: {}, + currentUser: CURRENT_USER + }; + const next = jest.fn(); + + getActivitySpy.mockResolvedValue(null); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.createSubmission(req as any, res as any, next); + + expect(getActivitySpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ activityId: '00000000' }); + }); + + it('creates submission with unique activity ID', async () => { + const req = { + body: SUBMISSION_1, + currentUser: CURRENT_USER + }; + const next = jest.fn(); + + getActivitySpy.mockResolvedValue(null); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.createSubmission(req as any, res as any, next); + + expect(getActivitySpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + }); + + it('attemps to create unique activity ID again on conflict', async () => { + const req = { + body: SUBMISSION_1, + currentUser: CURRENT_USER + }; + const next = jest.fn(); + + getActivitySpy.mockResolvedValueOnce({ activityId: '00000000', initiativeId: '123' }).mockResolvedValueOnce(null); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.createSubmission(req as any, res as any, next); + + expect(getActivitySpy).toHaveBeenCalledTimes(2); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + }); + + it('populates data from body if it exists', async () => { + const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + const req = { + body: { + applicant: { + firstName: 'Test', + lastName: 'User' + }, + basic: { + isDevelopedByCompanyOrOrg: true + }, + housing: { + projectName: 'TheProject' + }, + location: { + projectLocation: 'Some place' + }, + permits: { + hasAppliedProvincialPermits: true + } + }, + currentUser: CURRENT_USER + }; + const next = jest.fn(); + + getActivitySpy.mockResolvedValue(null); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.createSubmission(req as any, res as any, next); + + expect(getActivitySpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + contactName: `${req.body.applicant.firstName} ${req.body.applicant.lastName}`, + isDevelopedByCompanyOrOrg: true, + projectName: 'TheProject', + projectLocation: 'Some place', + hasAppliedProvincialPermits: true, + submissionId: expect.any(String), + activityId: expect.any(String), + submittedAt: expect.stringMatching(isoPattern), + intakeStatus: INTAKE_STATUS_LIST.SUBMITTED, + applicationStatus: APPLICATION_STATUS_LIST.NEW + }) + ); + }); + + it('creates permits if they exist', async () => { + const now = new Date().toISOString(); + + const req = { + body: { + appliedPermits: [ + { + permitTypeId: 1, + trackingId: '123', + status: 'Applied', + statusLastVerified: now + }, + { + permitTypeId: 3, + trackingId: '456', + status: 'Applied', + statusLastVerified: now + } + ], + investigatePermits: [ + { + permitTypeId: 12, + needed: 'Under investigation', + statusLastVerified: now + } + ] + }, + currentUser: CURRENT_USER + }; + const next = jest.fn(); + + getActivitySpy.mockResolvedValue(null); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + createPermitSpy.mockResolvedValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.createSubmission(req as any, res as any, next); + + expect(getActivitySpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + + expect(createPermitSpy).toHaveBeenCalledTimes(3); + expect(createPermitSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + permitTypeId: 1, + activityId: expect.any(String), + trackingId: '123', + status: 'Applied', + statusLastVerified: now + }) + ); + expect(createPermitSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + permitTypeId: 3, + activityId: expect.any(String), + trackingId: '456', + status: 'Applied', + statusLastVerified: now + }) + ); + expect(createPermitSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + permitTypeId: 12, + activityId: expect.any(String), + needed: 'Under investigation', + statusLastVerified: now + }) + ); + }); +}); + describe('getStatistics', () => { const next = jest.fn(); @@ -487,7 +633,8 @@ describe('getSubmission', () => { currentUser: CURRENT_USER }; - submissionSpy.mockResolvedValue(SUBMISSION_1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + submissionSpy.mockResolvedValue(SUBMISSION_1 as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any await submissionController.getSubmission(req as any, res as any, next); @@ -533,7 +680,8 @@ describe('getSubmissions', () => { }; checkAndStoreSpy.mockResolvedValue(); - submissionsSpy.mockResolvedValue([SUBMISSION_1]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + submissionsSpy.mockResolvedValue([SUBMISSION_1 as any]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await submissionController.getSubmissions(req as any, res as any, next); @@ -551,7 +699,8 @@ describe('getSubmissions', () => { }; checkAndStoreSpy.mockResolvedValue(); - submissionsSpy.mockResolvedValue([SUBMISSION_1]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + submissionsSpy.mockResolvedValue([SUBMISSION_1 as any]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await submissionController.getSubmissions(req as any, res as any, next); @@ -615,7 +764,8 @@ describe('updateSubmission', () => { const USR_IDENTITY = 'xxxy'; const USR_ID = 'abc-123'; - const updated = SUBMISSION_1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updated: any = SUBMISSION_1; updateSpy.mockResolvedValue(updated); getCurrentIdentitySpy.mockReturnValue(USR_IDENTITY); @@ -652,7 +802,8 @@ describe('updateSubmission', () => { await submissionController.updateSubmission(req as any, res as any, next); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenCalledWith({ ...(req.body as Submission), updatedBy: USR_ID }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(updateSpy).toHaveBeenCalledWith({ ...(req.body as any), updatedBy: USR_ID }); expect(res.status).toHaveBeenCalledTimes(0); expect(next).toHaveBeenCalledTimes(1); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b14459f7..5c27e585 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "primeicons": "^6.0.1", "primevue": "3.50.0", "qrcode.vue": "^3.4.1", + "quill": "^2.0.0", "uuid": "^9.0.1", "vee-validate": "^4.12.6", "vue": "^3.4.21", @@ -3581,6 +3582,11 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -3610,6 +3616,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4717,6 +4728,21 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, @@ -5133,6 +5159,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5456,6 +5487,33 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.0.tgz", + "integrity": "sha512-rVjwXhUD7svWsHPgzaT/O5D1xbZ5xeJjN2SZ/CxAkX1+WLV1VaALkmBt/elV/OCYNr/VapuXPoEZMYU5e72NPw==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index da09e099..ad41a185 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "primeicons": "^6.0.1", "primevue": "3.50.0", "qrcode.vue": "^3.4.1", + "quill": "^2.0.0", "uuid": "^9.0.1", "vee-validate": "^4.12.6", "vue": "^3.4.21", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 22d47186..4d521950 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@ import { storeToRefs } from 'pinia'; import { onBeforeMount, onErrorCaptured, ref } from 'vue'; import { RouterView } from 'vue-router'; + import { AppLayout, Navbar, ProgressLoader } from '@/components/layout'; import { ConfirmDialog, Message, Toast, useToast } from '@/lib/primevue'; import { useAppStore, useAuthStore, useConfigStore } from '@/store'; @@ -9,12 +10,15 @@ import { ToastTimeout } from '@/utils/constants'; import type { Ref } from 'vue'; +// Store const appStore = useAppStore(); const { getIsLoading } = storeToRefs(appStore); const { getConfig } = storeToRefs(useConfigStore()); +// State const ready: Ref = ref(false); +// Actions onBeforeMount(async () => { appStore.beginDeterminateLoading(); await useConfigStore().init(); diff --git a/frontend/src/assets/main.scss b/frontend/src/assets/main.scss index f6997d87..48053730 100644 --- a/frontend/src/assets/main.scss +++ b/frontend/src/assets/main.scss @@ -75,6 +75,8 @@ a:visited { label { color: #38598a; + display: inline-block; + margin-bottom: 0.5rem; } .wrap-block { @@ -157,7 +159,7 @@ label { } .p-checkbox.p-highlight .p-checkbox-box, -.p-radiobutton-checked .p-radiobutton-box, +.p-radiobutton.p-highlight .p-radiobutton-box, .p-inputswitch-checked .p-inputswitch-slider:not(.p-disabled) { background: $app-primary; border-color: $app-primary; diff --git a/frontend/src/components/file/FileUpload.vue b/frontend/src/components/file/FileUpload.vue index e7c0d078..4e884a91 100644 --- a/frontend/src/components/file/FileUpload.vue +++ b/frontend/src/components/file/FileUpload.vue @@ -12,9 +12,10 @@ import type { Ref } from 'vue'; // Props type Props = { activityId: string; + disabled?: boolean; }; -const props = withDefaults(defineProps(), {}); +const props = withDefaults(defineProps(), { disabled: false }); // Store const { getConfig } = storeToRefs(useConfigStore()); @@ -27,25 +28,39 @@ const fileInput: Ref = ref(null); const toast = useToast(); const onFileUploadClick = () => { + if (props.disabled) { + toast.info('Document uploading is currently disabled'); + return; + } + fileInput.value.click(); }; const onFileUploadDragAndDrop = (event: FileUploadUploaderEvent) => { - onUpload(Array.isArray(event.files) ? event.files[0] : event.files); + if (props.disabled) { + toast.info('Document uploading is currently disabled'); + return; + } + + onUpload(Array.isArray(event.files) ? event.files : [event.files]); }; -const onUpload = async (file: File) => { - try { - const response = (await documentService.createDocument(file, props.activityId, getConfig.value.coms.bucketId)) - ?.data; - - if (response) { - submissionStore.addDocument(response); - toast.success('Document uploaded'); - } - } catch (e: any) { - toast.error('Failed to upload document', e); - } +const onUpload = async (files: Array) => { + await Promise.allSettled( + files.map(async (file: File) => { + try { + const response = (await documentService.createDocument(file, props.activityId, getConfig.value.coms.bucketId)) + ?.data; + + if (response) { + submissionStore.addDocument(response); + toast.success('Document uploaded'); + } + } catch (e: any) { + toast.error('Failed to upload document', e); + } + }) + ); }; @@ -53,9 +68,10 @@ const onUpload = async (file: File) => {
@@ -88,7 +104,8 @@ const onUpload = async (file: File) => { type="file" style="display: none" accept="*" - @change="(event: any) => onUpload(event.target.files[0])" + multiple + @change="(event: any) => onUpload(Array.from(event.target.files))" @click="(event: any) => (event.target.value = null)" />
diff --git a/frontend/src/components/form/Calendar.vue b/frontend/src/components/form/Calendar.vue index 054723c9..b81642b0 100644 --- a/frontend/src/components/form/Calendar.vue +++ b/frontend/src/components/form/Calendar.vue @@ -12,6 +12,7 @@ type Props = { disabled?: boolean; showTime?: boolean; bold?: boolean; + placeholder?: string; }; const props = withDefaults(defineProps(), { @@ -20,14 +21,15 @@ const props = withDefaults(defineProps(), { label: '', disabled: false, showTime: false, - bold: true + bold: true, + placeholder: '' }); const { errorMessage, value } = useField(toRef(props, 'name'));