diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index c7f8234949..ec7f655f76 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -11,6 +11,7 @@ import { ConfigService } from '@nestjs/config'; import { SchedulerRegistry } from '@nestjs/schedule'; import { LanguagesEnum, + ListingEventsTypeEnum, ListingsStatusEnum, Prisma, ReviewOrderTypeEnum, @@ -48,6 +49,7 @@ import { startCronJob } from '../utilities/cron-job-starter'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import Unit from '../dtos/units/unit.dto'; +import { checkIfDatesChanged } from '../utilities/listings-utilities'; export type getListingsArgs = { skip: number; @@ -1152,6 +1154,8 @@ export class ListingService implements OnModuleInit { */ async update(dto: ListingUpdate, requestingUser: User): Promise { const storedListing = await this.findOrThrow(dto.id, ListingViews.details); + const isNonAdmin = !requestingUser?.userRoles?.isAdmin; + const isActiveListing = dto.status === ListingsStatusEnum.active; await this.permissionService.canOrThrow( requestingUser, @@ -1163,6 +1167,31 @@ export class ListingService implements OnModuleInit { }, ); + //check if the user has permission to edit dates + if (isNonAdmin && isActiveListing) { + const lotteryEvent = dto.listingEvents?.find( + (event) => event?.type === ListingEventsTypeEnum.publicLottery, + ); + const storedLotteryEvent = storedListing.listingEvents?.find( + (event) => event?.type === ListingEventsTypeEnum.publicLottery, + ); + + if ( + checkIfDatesChanged( + lotteryEvent, + storedLotteryEvent, + dto, + storedListing.applicationDueDate?.toISOString(), + storedListing.reviewOrderType, + ) + ) { + throw new HttpException( + 'You do not have permission to edit dates', + 403, + ); + } + } + dto.unitsAvailable = dto.reviewOrderType !== ReviewOrderTypeEnum.waitlist && dto.units ? dto.units.length @@ -1600,7 +1629,7 @@ export class ListingService implements OnModuleInit { clears the listing cache of either 1 listing or all listings @param storedListingStatus the status that was stored for the listing @param incomingListingStatus the incoming "new" status for a listing - @param savedResponseId the id of the listing + @param savedResponseId the id of the listing */ async cachePurge( storedListingStatus: ListingsStatusEnum | undefined, @@ -1724,7 +1753,7 @@ export class ListingService implements OnModuleInit { /** marks the db record for this cronjob as begun or creates a cronjob that - is marked as begun if one does not already exist + is marked as begun if one does not already exist */ async markCronJobAsStarted(): Promise { const job = await this.prisma.cronJob.findFirst({ diff --git a/api/src/utilities/listings-utilities.ts b/api/src/utilities/listings-utilities.ts new file mode 100644 index 0000000000..1c82b564fe --- /dev/null +++ b/api/src/utilities/listings-utilities.ts @@ -0,0 +1,39 @@ +import { ListingEventsTypeEnum } from '@prisma/client'; +import { ListingUpdate } from '../dtos/listings/listing-update.dto'; +import { ListingEventCreate } from '../dtos/listings/listing-event-create.dto'; +import { ListingEvent } from '../dtos/listings/listing-event.dto'; + +export const checkIfDatesChanged = ( + lotteryEvent: ListingEventCreate, + storedLotteryEvent: ListingEvent, + dto: ListingUpdate, + storedApplicationDueDate: string, + storedReviewOrderType: string, +) => { + const isPublicLotteryEvent = + lotteryEvent?.type === ListingEventsTypeEnum.publicLottery; + + const isSameStartDate = + lotteryEvent?.startDate?.toISOString() === + storedLotteryEvent?.startDate?.toISOString(); + const isSameStartTime = + lotteryEvent?.startTime?.toISOString() === + storedLotteryEvent?.startTime?.toISOString(); + const isSameEndTime = + lotteryEvent?.endTime?.toISOString() === + storedLotteryEvent?.endTime?.toISOString(); + + const isDifferentReviewOrderType = + storedReviewOrderType !== dto?.reviewOrderType; + const isDifferentApplicationDueDate = + dto.applicationDueDate?.toISOString() !== storedApplicationDueDate; + const isDifferentLotteryEventTimes = + isPublicLotteryEvent && + !(isSameStartDate && isSameStartTime && isSameEndTime); + + return ( + isDifferentReviewOrderType || + isDifferentLotteryEventTimes || + isDifferentApplicationDueDate + ); +}; diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index a3d92085e2..879243a8d1 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -1116,6 +1116,8 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); const val = await constructFullListingData(prisma, listing.id, jurisId); + val.applicationDueDate = listing.applicationDueDate; + val.reviewOrderType = listing.reviewOrderType; await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1300,7 +1302,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for process endpoint', async () => { /* - Because so many different iterations of the process endpoint were firing we were running into collisions. + Because so many different iterations of the process endpoint were firing we were running into collisions. Since this is just testing the permissioning aspect I'm switching to mocking the process function */ applicationFlaggedSetService.process = jest.fn(); diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts index ccab198ad3..726ccb2d2a 100644 --- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts @@ -1051,6 +1051,8 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( userListingId, jurisId, ); + val.applicationDueDate = new Date('05-16-2024 01:25:18PM GMT+2'); + val.reviewOrderType = null; await request(app.getHttpServer()) .put(`/listings/${userListingId}`) @@ -1213,7 +1215,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for process endpoint', async () => { /* - Because so many different iterations of the process endpoint were firing we were running into collisions. + Because so many different iterations of the process endpoint were firing we were running into collisions. Since this is just testing the permissioning aspect I'm switching to mocking the process function */ applicationFlaggedSetService.process = jest.fn(); diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index df63673439..a32e2c5d5b 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -41,8 +41,8 @@ import { permissionActions } from '../../../src/enums/permissions/permission-act import { disconnect } from 'process'; /* - generates a super simple mock listing for us to test logic with -*/ + generates a super simple mock listing for us to test logic with + */ const mockListing = ( pos: number, genUnits?: { numberToMake: number; date: Date }, @@ -2407,6 +2407,8 @@ describe('Testing listing service', () => { const val = constructFullListingData(randomUUID()); val.reservedCommunityTypes = null; + val.applicationDueDate = undefined; + val.reviewOrderType = undefined; await service.update(val as ListingUpdate, user); diff --git a/sites/partners/src/components/listings/PaperListingForm/index.tsx b/sites/partners/src/components/listings/PaperListingForm/index.tsx index 05360510f0..f51109b3cd 100644 --- a/sites/partners/src/components/listings/PaperListingForm/index.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/index.tsx @@ -62,6 +62,7 @@ type ListingFormProps = { // eslint-disable-next-line @typescript-eslint/no-unused-vars const ListingForm = ({ listing, editMode }: ListingFormProps) => { const defaultValues = editMode ? listing : formDefaults + const isListingActive = listing?.status === ListingsStatusEnum.active const formMethods = useForm({ defaultValues, shouldUnregister: false, @@ -147,7 +148,7 @@ const ListingForm = ({ listing, editMode }: ListingFormProps) => { newData?: Partial ) => { if (confirm && status === ListingsStatusEnum.active) { - if (listing?.status === ListingsStatusEnum.active) { + if (isListingActive) { setListingIsAlreadyLiveModal(true) } else { setPublishModal(true) @@ -314,6 +315,9 @@ const ListingForm = ({ listing, editMode }: ListingFormProps) => { units={units} setUnits={setUnits} disableUnitsAccordion={listing?.disableUnitsAccordion} + disableListingAvailability={ + isListingActive && !profile.userRoles.isAdmin + } /> { - + @@ -378,6 +385,7 @@ const ListingForm = ({ listing, editMode }: ListingFormProps) => { listing={listing} openHouseEvents={openHouseEvents} setOpenHouseEvents={setOpenHouseEvents} + disableDueDate={isListingActive && !profile.userRoles.isAdmin} />
diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/ApplicationDates.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/ApplicationDates.tsx index edbb36282c..a918beed17 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/ApplicationDates.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/ApplicationDates.tsx @@ -13,12 +13,14 @@ type ApplicationDatesProps = { openHouseEvents: TempEvent[] setOpenHouseEvents: (events: TempEvent[]) => void listing?: FormListing + disableDueDate?: boolean } const ApplicationDates = ({ listing, openHouseEvents, setOpenHouseEvents, + disableDueDate, }: ApplicationDatesProps) => { const openHouseHeaders = { date: "t.date", @@ -116,7 +118,7 @@ const ApplicationDates = ({ register={register} watch={watch} note={t("listings.whenApplicationsClose")} - disabled={enableDueDate === YesNoEnum.no} + disabled={disableDueDate || enableDueDate === YesNoEnum.no} defaultDate={{ month: listing?.applicationDueDate ? dayjs(new Date(listing?.applicationDueDate)).format("MM") @@ -137,7 +139,7 @@ const ApplicationDates = ({ id={"applicationDueTimeField"} register={register} watch={watch} - disabled={enableDueDate === YesNoEnum.no} + disabled={disableDueDate || enableDueDate === YesNoEnum.no} defaultValues={{ hours: listing?.applicationDueDate ? dayjs(new Date(listing?.applicationDueDate)).format("hh") diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx index 1a764bea44..3643892930 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx @@ -16,9 +16,10 @@ import SectionWithGrid from "../../../shared/SectionWithGrid" type RankingsAndResultsProps = { listing?: FormListing + disableDueDates?: boolean } -const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { +const RankingsAndResults = ({ listing, disableDueDates }: RankingsAndResultsProps) => { const formMethods = useFormContext() // eslint-disable-next-line @typescript-eslint/unbound-method @@ -79,6 +80,8 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { label: t("listings.firstComeFirstServe"), value: "reviewOrderFCFS", id: "reviewOrderFCFS", + disabled: + disableDueDates && listing?.reviewOrderType === ReviewOrderTypeEnum.lottery, defaultChecked: listing?.reviewOrderType === ReviewOrderTypeEnum.firstComeFirstServe, }, @@ -86,6 +89,9 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { label: t("listings.lotteryTitle"), value: "reviewOrderLottery", id: "reviewOrderLottery", + disabled: + disableDueDates && + listing?.reviewOrderType === ReviewOrderTypeEnum.firstComeFirstServe, defaultChecked: listing?.reviewOrderType === ReviewOrderTypeEnum.lottery, }, ]} @@ -105,11 +111,13 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { { ...yesNoRadioOptions[0], id: "dueDateQuestionYes", + disabled: disableDueDates && !listing?.applicationDueDate, defaultChecked: listing && listing.applicationDueDate !== null, }, { ...yesNoRadioOptions[1], id: "dueDateQuestionNo", + disabled: disableDueDates && listing?.applicationDueDate !== null, defaultChecked: listing && !listing.applicationDueDate, }, ]} @@ -127,6 +135,7 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { id={"lotteryDate"} register={register} watch={watch} + disabled={disableDueDates} defaultDate={{ month: lotteryEvent?.startDate ? dayjs(new Date(lotteryEvent?.startDate)).utc().format("MM") @@ -147,6 +156,7 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { id={"lotteryStartTime"} register={register} watch={watch} + disabled={disableDueDates} defaultValues={{ hours: lotteryEvent?.startTime ? dayjs(new Date(lotteryEvent?.startTime)).format("hh") @@ -168,6 +178,7 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { id={"lotteryEndTime"} register={register} watch={watch} + disabled={disableDueDates} defaultValues={{ hours: lotteryEvent?.endTime ? dayjs(new Date(lotteryEvent?.endTime)).format("hh") diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/Units.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/Units.tsx index 533f4419bb..49897aaef4 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/Units.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/Units.tsx @@ -20,9 +20,15 @@ type UnitProps = { units: TempUnit[] setUnits: (units: TempUnit[]) => void disableUnitsAccordion: boolean + disableListingAvailability?: boolean } -const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { +const FormUnits = ({ + units, + setUnits, + disableUnitsAccordion, + disableListingAvailability, +}: UnitProps) => { const [unitDrawerOpen, setUnitDrawerOpen] = useState(false) const [unitDeleteModal, setUnitDeleteModal] = useState(null) const [defaultUnit, setDefaultUnit] = useState(null) @@ -185,6 +191,9 @@ const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { id: "availableUnits", dataTestId: "listingAvailability.availableUnits", defaultChecked: listing?.reviewOrderType !== ReviewOrderTypeEnum.waitlist, + disabled: + disableListingAvailability && + listing?.reviewOrderType === ReviewOrderTypeEnum.waitlist, }, { label: t("listings.waitlist.open"), @@ -192,6 +201,9 @@ const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { id: "openWaitlist", dataTestId: "listingAvailability.openWaitlist", defaultChecked: listing?.reviewOrderType === ReviewOrderTypeEnum.waitlist, + disabled: + disableListingAvailability && + listing?.reviewOrderType !== ReviewOrderTypeEnum.waitlist, }, ]} />