Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,6 @@ jobs:
runs-on: ubuntu-latest
if: ${{ needs.meta.outputs.tag_exists == 'false' }}
container: node:22.19.0-bookworm
services:
db:
image: postgres:17.6
env:
POSTGRES_PASSWORD: postgres
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
steps:
- name: Check out repository code
uses: actions/checkout@v4
Expand All @@ -76,8 +69,10 @@ jobs:
run: npm run prisma:generate -w server && npm run build:forms -w server
- name: Run ESLint
run: npm run lint:check
- name: Run Tests
run: cp server/example.env server/.env && npm test
- name: Run Client Tests
run: npm test --workspace client
- name: Run Server Tests
run: cp server/example.env server/.env && npm test --workspace server

build:
name: Build Image
Expand Down Expand Up @@ -164,7 +159,7 @@ jobs:
name: Deploy
needs: [ meta, merge ]
if: |
always()
(github.ref_name == 'dev' || github.ref_name == 'main')
&& (needs.meta.outputs.tag_exists == 'true' || needs.merge.result == 'success')
runs-on: ubuntu-latest
environment: ${{ needs.meta.outputs.environment }}
Expand Down
48 changes: 11 additions & 37 deletions client/src/lesc/components/PropertyForm.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Head } from '@unhead/react';
import { IconArrowLeft, IconX } from '@tabler/icons-react';
import { Accordion, Anchor, Badge, Box, Button, CloseButton, Container, Divider, Fieldset, Group, Image, Stack, Text, Textarea, Title } from '@mantine/core';
Expand All @@ -13,7 +13,6 @@ import ChipInput from '@/components/ChipInput';
import Header from '@/components/Header';
import IconButtonLink from '@/components/IconButtonLink';
import PhotoInput from '@/components/PhotoInput';
import { useToast } from '@/components/ToastContext';
import { validateProperty } from '@/utils/validators';

const initialValues = {
Expand All @@ -24,14 +23,11 @@ const maxPropertyPhotos = 5;

function PropertyForm () {
const navigate = useNavigate();
const location = useLocation();
const { id } = useParams();
const [searchParams] = useSearchParams();
const isNew = searchParams.get('isNew') === 'true';
const isCustodyContext = location.pathname.startsWith('/custody');
const queryClient = useQueryClient();
const { facility } = useFacilityContext();
const { showToast } = useToast();
const { t } = useTranslation();
const [isLarge, setIsLarge] = useState(false);
const autoSaveTimerRef = useRef(null);
Expand All @@ -46,7 +42,7 @@ function PropertyForm () {
initialValues,
onValuesChange: (values) => {
setIsLarge(values.property === 'LARGE');
if (form.initialized && !isCustodyContext) {
if (form.initialized) {
scheduleAutoSave(values);
}
},
Expand Down Expand Up @@ -93,11 +89,6 @@ function PropertyForm () {
async function updateDeflectionCache (updatedDeflection) {
await queryClient.setQueryData(['deflections', id], updatedDeflection);
queryClient.invalidateQueries({ queryKey: ['facilities', facility.id, 'my-holds'] });
if (isCustodyContext) {
queryClient.invalidateQueries({ queryKey: ['deflections', String(id)] });
queryClient.invalidateQueries({ queryKey: ['deflections', facility.id] });
queryClient.invalidateQueries({ queryKey: ['deflections'] });
}
}

const autoSaveMutation = useMutation({
Expand All @@ -111,18 +102,8 @@ function PropertyForm () {
mutationFn: (data) => Api.deflections.update(id, data),
onSuccess: async (response) => {
await updateDeflectionCache(response.data);
if (isCustodyContext) {
showToast('Property details saved', 'success', 4000, 'Property details have been saved.');
navigate(`/custody/${id}`);
return;
}
navigate(isNew ? `/holds/${id}/certify?isNew=true` : `/holds/${id}`);
},
onError: () => {
if (!isCustodyContext) return;
showToast('We couldn’t save property details', 'error', 4000, 'Please try again.');
navigate(`/custody/${id}`);
},
});

const uploadPhotoMutation = useMutation({
Expand Down Expand Up @@ -170,29 +151,24 @@ function PropertyForm () {
const propertyPhotos = deflection?.propertyPhotos ?? [];

let header;
if (onSubmitMutation.isPending || (!isCustodyContext && autoSaveMutation.isPending)) {
if (onSubmitMutation.isPending || autoSaveMutation.isPending) {
header = <Text c='dimmed' size='lg'>Saving...</Text>;
} else if (!isCustodyContext && (onSubmitMutation.isSuccess || autoSaveMutation.isSuccess)) {
} else if (onSubmitMutation.isSuccess || autoSaveMutation.isSuccess) {
header = <Text c='teal.6' size='lg'>Changes saved</Text>;
} else if (!isCustodyContext && (onSubmitMutation.isError || autoSaveMutation.isError)) {
} else if (onSubmitMutation.isError || autoSaveMutation.isError) {
header = <Text c='red.6' size='lg'>Save failed</Text>;
}

const detailPath = isCustodyContext ? `/custody/${id}` : `/holds/${id}`;
const backPath = isCustodyContext ? detailPath : (isNew ? `/holds/${id}/deflection?isNew=true` : detailPath);
const closePath = isCustodyContext ? detailPath : '/holds';
const headerJustify = isCustodyContext ? 'flex-end' : 'space-between';

return (
<>
<Head>
<title>Personal property</title>
</Head>
<Header>
<Group w='100%' justify={headerJustify}>
{!isCustodyContext && <IconButtonLink icon={IconArrowLeft} to={backPath} aria-label='Go back' />}
<Group w='100%' justify='space-between'>
<IconButtonLink icon={IconArrowLeft} to={isNew ? `/holds/${id}/deflection?isNew=true` : `/holds/${id}`} aria-label='Go back' />
{header}
<IconButtonLink icon={IconX} to={closePath} aria-label='Close' />
<IconButtonLink icon={IconX} to='/holds' aria-label='Close' />
</Group>
</Header>
<Container>
Expand All @@ -205,16 +181,14 @@ function PropertyForm () {
<Title order={2}>Personal property</Title>
{isNew && <Badge variant='light' color='gray' size='lg' radius='xl'>4/5</Badge>}
</Group>
<Text c='dimmed' size='md' mb='xl'>
{isCustodyContext ? 'Record the amount of personal property.' : 'Document any personal property the person is bringing.'}
</Text>
<Text c='dimmed' size='md' mb='xl'>Document any personal property the person is bringing.</Text>
<form onSubmit={form.onSubmit(onSubmitMutation.mutateAsync)}>
<Fieldset disabled={isLoading || onSubmitMutation.isPending} variant='unstyled'>
<Stack gap='xl'>
<ChipInput
{...form.getInputProps('property')}
key={form.key('property')}
label={<>{isCustodyContext ? 'How much personal property does the person have?' : 'How much property is the person bringing?'}<span>*</span></>}
label={<>How much property is the person bringing?<span>*</span></>}
options={['NONE', 'SMALL', 'MEDIUM', 'LARGE'].map(value => ({
value,
label: t(`property.${value}`),
Expand Down Expand Up @@ -285,7 +259,7 @@ function PropertyForm () {
</Accordion.Item>
</Accordion>
<Button type='submit' mb='xl'>
{isNew ? 'Next: Certify' : (isCustodyContext ? 'Save changes' : 'Save property')}
{isNew ? 'Next: Certify' : 'Save property'}
</Button>
</Stack>
</Fieldset>
Expand Down
12 changes: 1 addition & 11 deletions client/src/lesc/components/custody/CustodyDetailContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ const CUSTODY_ACTION_FOOTER_STATUSES = ['AWAITING_INTAKE', 'FAILED_INTAKE', 'REA
const HOSPITAL_RELEASE_ELIGIBLE_STATUSES = ['AWAITING_INTAKE', 'READY_FOR_INTAKE', 'IN_MEDICAL_INTAKE', 'IN_CHAIR'];
const OTHER_EXIT_ELIGIBLE_STATUSES = ['AWAITING_INTAKE', 'READY_FOR_INTAKE', 'IN_MEDICAL_INTAKE', 'IN_CHAIR'];
const PRE_TRANSFER_STATUSES = ['DETAINED', 'ONSITE_AWAITING_TRANSFER'];
const CUSTODY_PROPERTY_EDIT_STATUSES = ['AWAITING_INTAKE', 'READY_FOR_INTAKE', 'FAILED_INTAKE', 'IN_MEDICAL_INTAKE', 'IN_CHAIR', 'RELEASED'];
const PROPERTY_RETURN_TOAST_KEY = 'custodyPropertyReturnToast';

function CustodyDetailContent ({ deflection, backTo = '/custody', viewerMode = 'custody' }) {
Expand Down Expand Up @@ -67,7 +66,6 @@ function CustodyDetailContent ({ deflection, backTo = '/custody', viewerMode = '
const isArrived = deflection?.subjectStatus === 'ONSITE_AWAITING_TRANSFER';
const isPostRelease = isLegallyReleased || isExited;
const canEditCustodyDetails = !isCareView && !isPreTransfer && !isPostRelease;
const canEditCustodyProperty = !isCareView && isCustody && CUSTODY_PROPERTY_EDIT_STATUSES.includes(deflection?.subjectStatus);
const now = useNow(1000, isPreTransfer && !isArrived && !!deflection?.expiresAt);
const expiresIn = isPreTransfer && !isArrived && deflection?.expiresAt
? formatTimeRemaining(deflection.expiresAt, now)
Expand Down Expand Up @@ -467,10 +465,7 @@ function CustodyDetailContent ({ deflection, backTo = '/custody', viewerMode = '
</Accordion.Item>
<Accordion.Item value='property' id={propertySectionId}>
<Accordion.Control>
<Title order={3}>Personal property</Title>
{deflection?.id && (
<Text c='gray.5' size='sm'>Linked to Hold {deflection.id}.</Text>
)}
<Title order={3}>Property details</Title>
</Accordion.Control>
<Accordion.Panel>
<Stack gap='sm'>
Expand Down Expand Up @@ -502,11 +497,6 @@ function CustodyDetailContent ({ deflection, backTo = '/custody', viewerMode = '
{!!propertyReturnStatusText && (
<Text c={deflection?.propertyReturned ? 'teal.6' : 'yellow.8'}>{propertyReturnStatusText}</Text>
)}
{canEditCustodyProperty && (
<Group mt='sm'>
<Button variant='secondary' size='md' onClick={() => navigate(`/custody/${deflection?.id}/property`)}>Edit</Button>
</Group>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
Expand Down
28 changes: 2 additions & 26 deletions client/src/lesc/components/custody/CustodyDetailContent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,7 @@ describe('CustodyDetailContent', () => {
expect(html).toContain('Record exit to other');
expect(html).toContain('Behavioral observations');
expect(html).toContain('Substance-related details');
expect(html).toContain('Personal property');
expect(html).toContain('Linked to Hold 123456.');
expect(html).toContain('Property details');
expect(html).toContain('Incident details');
expect(html).toContain('CASE-456');

Expand All @@ -277,7 +276,7 @@ describe('CustodyDetailContent', () => {

expect(html).toContain('849(b) release narrative');
expect(html).toContain('Any narrative edits will automatically update the 849(b) document.');
expect((html.match(/>Edit</g) || [])).toHaveLength(3);
expect((html.match(/>Edit</g) || [])).toHaveLength(2);
expect(html).not.toContain('<textarea');
});

Expand Down Expand Up @@ -315,29 +314,6 @@ describe('CustodyDetailContent', () => {
expect(html).toContain('Edit narrative');
});

it('allows property edits after legal release until exit', () => {
const html = render({
subjectStatus: 'RELEASED',
releasedAt: '2026-01-01T11:00:00.000Z',
});

expect(html).toContain('Personal property');
expect((html.match(/>Edit</g) || [])).toHaveLength(1);
expect(html).toContain('Edit narrative');
});

it('does not allow property edits after exit', () => {
const html = render({
subjectStatus: 'EXITED',
releasedAt: '2026-01-01T11:00:00.000Z',
exitedAt: '2026-01-01T12:00:00.000Z',
});

expect(html).toContain('Personal property');
expect(html).not.toContain('>Edit</button>');
expect(html).toContain('Edit narrative');
});

it('builds the default 849(b) narrative from case number, cad number, and 647(f) narrative', () => {
const html = render({ releaseNarrative: null });

Expand Down
1 change: 0 additions & 1 deletion client/src/lesc/routes/LESCRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ function LESCRoutes () {
<Route path='incident' element={<IncidentForm />} />
<Route path='incident/handoff' element={<HandoffScreen />} />
<Route path='custody/:id/subject' element={<SubjectForm />} />
<Route path='custody/:id/property' element={<PropertyForm />} />
<Route path='custody/:id/legal-release' element={<LegalReleaseQuestions />} />
<Route path='custody/:id/property-return' element={<RecordPropertyReturn />} />
<Route path='custody/:id/release-narrative' element={<CustodyReleaseNarrativeForm />} />
Expand Down
25 changes: 2 additions & 23 deletions e2e/849b-pdf-fields.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,6 @@ test.describe('849(b) PDF field verification', () => {
expect(getFieldText('REPORTING DEPUTY PRINT')).toBe('A. User');
});

test('watch and assignment routing fields are populated', () => {
expect(getFieldText('WATCH')).toMatch(/^(0700-1900|1900-0700)$/);
expect(getFieldText('ASSIGN TO')).toBe('COMMUNITY PROGRAMS');
expect(getFieldText('COPIES TO DDL UNITSGENCIES')).toBe('RECORDS');
});

test('type of premise is blank', () => {
const value = getFieldText('TYPE OF PREMISE');
expect(value || '').toBe('');
Expand Down Expand Up @@ -168,8 +162,8 @@ test.describe('849(b) PDF field verification', () => {
expect(getFieldText('NAME LAST FIRST MIDDLE_2')).toBe('SFSO, T, #5678');
});

test('reporting party phone number is business phone', () => {
expect(getFieldText('BUSINESS PHONE_2')).toBe('415-575-6461');
test('reporting party contact phone number', () => {
expect(getFieldText('CONTACT PHONE NUMBER_2')).toBe('415-575-6461');
});

test('reporting party business address', () => {
Expand All @@ -182,21 +176,6 @@ test.describe('849(b) PDF field verification', () => {
expect(getFieldText('ZIP CODE_4')).toBe('94107');
});

test('reporting party notification fields default to no with releasing deputy star', () => {
expect(pdfForm.getRadioGroup('293 PC NOTIFICATION').getSelected()).toBe('NO_3');
expect(pdfForm.getRadioGroup('CONFIDENTIALITY REQUESTED').getSelected()).toBe('NO_4');
expect(getFieldText('STAR_3')).toBe('5678');
expect(pdfForm.getRadioGroup('VICTIM OF CRIME NOTIFICATION').getSelected()).toBe('NO_5');
expect(pdfForm.getRadioGroup('FOLLOW UP FORM').getSelected()).toBe('NO_6');
expect(pdfForm.getRadioGroup('STATEMENT_2').getSelected()).toBe('NO_7');
});

test('reporting party other information uses SFSO employment text', () => {
expect(
getFieldText('OTHER INFORMATION SUBJECT LAST SEEN WEARINGEMPLOYMENTACTIVITY AT TIME OF INCIDENT')
).toBe('At the time of reporting, employed by SFSO');
});

test('citation area does not contain stray text', () => {
expect(getFieldText('Text4') || '').toBe('');
});
Expand Down
11 changes: 4 additions & 7 deletions server/lib/forms/849b/fill849b.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* const filledBytes = await fill849b(templatePdfBytes, data);
*/

import { PDFDocument, PDFName, StandardFonts } from 'pdf-lib';
import { PDFDocument, PDFName, PDFBool } from 'pdf-lib';

// ═══════════════════════════════════════════════════════════════════
// 1:1 TEXT FIELD MAPPINGS { camelCaseKey → pdfFieldName }
Expand Down Expand Up @@ -334,12 +334,9 @@ export async function fill849b (pdfBytes, data) {
applyDobAge(form, 'DOBAGE', data, 'subject');
applyDobAge(form, 'DOBAGE_2', data, 'reportingParty');

// ── Bake printable appearances into the file instead of relying on the viewer ──
const appearanceFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
form.updateFieldAppearances(appearanceFont);

// ── Preserve the form's native fonts: let the viewer render appearances ──
const acroForm = pdfDoc.catalog.lookup(PDFName.of('AcroForm'));
acroForm.delete(PDFName.of('NeedAppearances'));
acroForm.set(PDFName.of('NeedAppearances'), PDFBool.True);

return pdfDoc.save();
return pdfDoc.save({ updateFieldAppearances: false });
}
Loading
Loading