diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index f049743..c2d83a5 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -170,8 +170,8 @@ services: # Auth Service auth: build: - context: ./lambdas/auth - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/auth/Dockerfile container_name: branch-auth restart: unless-stopped environment: diff --git a/apps/backend/lambdas/auth/Dockerfile b/apps/backend/lambdas/auth/Dockerfile index 46cb374..82c773b 100644 --- a/apps/backend/lambdas/auth/Dockerfile +++ b/apps/backend/lambdas/auth/Dockerfile @@ -1,15 +1,21 @@ FROM node:20-alpine +WORKDIR /shared/types + +# Copy shared types (required by auth lambda) +COPY shared/types/package.json ./ +COPY shared/types/ ./ + WORKDIR /app # Copy package files -COPY package*.json ./ +COPY apps/backend/lambdas/auth/package*.json ./ # Install dependencies -RUN npm install +RUN npm install --no-package-lock # Copy source files -COPY . . +COPY apps/backend/lambdas/auth/ . # Expose port EXPOSE 3000 diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 8ce9b37..2ef8793 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -44,7 +44,7 @@ export const handler = async (event: any): Promise => { const normalizedPath = rawPath.replace(/\/$/, ''); const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - // CORS preflight — must return 2xx or the browser blocks the request. + // CORS preflight if (method === 'OPTIONS') { return json(200, {}); } diff --git a/apps/frontend/src/app/components/UploadReportModal.tsx b/apps/frontend/src/app/components/UploadReportModal.tsx new file mode 100644 index 0000000..447c25c --- /dev/null +++ b/apps/frontend/src/app/components/UploadReportModal.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; +import DropdownSelector from './DropdownSelector'; +import { uploadReport, type Project } from '@/lib/reports'; + +const REPORT_TYPES = ['Technical', 'Narrative']; +const ACCEPTED_EXTENSIONS = ['.pdf', '.docx']; + +interface UploadReportModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + projects: Project[]; +} + +export default function UploadReportModal({ + open, + onClose, + onSuccess, + projects, +}: UploadReportModalProps) { + const fileInputRef = useRef(null); + + const [file, setFile] = useState(null); + const [title, setTitle] = useState(''); + const [projectName, setProjectName] = useState(''); + const [reportType, setReportType] = useState(''); + + const [fileError, setFileError] = useState(false); + const [titleError, setTitleError] = useState(false); + const [projectError, setProjectError] = useState(false); + const [reportTypeError, setReportTypeError] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [loading, setLoading] = useState(false); + + function resetForm() { + setFile(null); + setTitle(''); + setProjectName(''); + setReportType(''); + setFileError(false); + setTitleError(false); + setProjectError(false); + setReportTypeError(false); + setSubmitError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + function handleClose() { + resetForm(); + onClose(); + } + + function handleFileChange(e: React.ChangeEvent) { + const selected = e.target.files?.[0] ?? null; + if (selected) { + const ext = '.' + selected.name.split('.').pop()?.toLowerCase(); + if (!ACCEPTED_EXTENSIONS.includes(ext)) { + setFileError(true); + setFile(null); + return; + } + } + setFile(selected); + setFileError(false); + } + + async function handleSubmit() { + const hasFileError = !file; + const hasTitleError = !title.trim(); + const hasProjectError = !projectName; + const hasReportTypeError = !reportType; + + setFileError(hasFileError); + setTitleError(hasTitleError); + setProjectError(hasProjectError); + setReportTypeError(hasReportTypeError); + + if (hasFileError || hasTitleError || hasProjectError || hasReportTypeError) return; + + const selectedProject = projects.find((p) => p.name === projectName); + if (!selectedProject) { + setProjectError(true); + return; + } + + setLoading(true); + setSubmitError(null); + + try { + await uploadReport( + file!, + title.trim(), + selectedProject.project_id, + reportType.toLowerCase() as 'technical' | 'narrative', + ); + resetForm(); + onSuccess(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to upload report'); + } finally { + setLoading(false); + } + } + + return ( + { if (!e.open) handleClose(); }}> + + + + + + + Upload New Report + + + + + + + {/* File picker */} +
+ + + {fileError && ( + + Select a PDF or DOCX file + + )} +
+ + {/* Title */} +
+ + { setTitle(e.target.value); setTitleError(false); }} + placeholder="Enter report title" + style={{ + border: `1px solid ${titleError ? 'var(--color-error-red)' : '#CBD5E0'}`, + borderRadius: '6px', + padding: '8px 12px', + fontSize: '14px', + outline: 'none', + width: '100%', + fontFamily: 'inherit', + }} + /> + {titleError && ( + + Enter a title + + )} +
+ + {/* Project */} +
+ + p.name)} + placeholder="Select a project" + multiSelect={false} + value={projectName} + onChange={(val) => { setProjectName(val as string); setProjectError(false); }} + /> + {projectError && ( + + Select a project + + )} +
+ + {/* Report type */} +
+ + { setReportType(val as string); setReportTypeError(false); }} + /> + {reportTypeError && ( + + Select a report type + + )} +
+ + {submitError && ( +

+ {submitError} +

+ )} +
+
+ + + + + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx index 35673be..8664c84 100644 --- a/apps/frontend/src/app/reports/page.tsx +++ b/apps/frontend/src/app/reports/page.tsx @@ -16,6 +16,8 @@ import { VStack, } from '@chakra-ui/react'; import { useApi } from '@/hooks/useApi'; +import { type Project } from '@/lib/reports'; +import UploadReportModal from '../components/UploadReportModal'; import { FaPlus } from 'react-icons/fa'; import { LuClipboardPenLine } from 'react-icons/lu'; import { RiDeleteBack2Line } from "react-icons/ri"; @@ -32,11 +34,6 @@ type Report = { emails?: string[]; }; -type Project = { - project_id: number; - name: string; -}; - const ROWS_PER_PAGE = 10; const EXTENSION_LABELS: Record = { @@ -86,6 +83,9 @@ function ReportsPageContent() { // Selected rows (checkboxes) for bulk delete const [selectedIds, setSelectedIds] = useState([]); + // Upload modal + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + // Tab: Reports vs Schedule const [activeTab, setActiveTab] = useState<'reports' | 'schedule'>('reports'); @@ -182,10 +182,8 @@ function ReportsPageContent() { */ } - // New Report handler - // NOTE: no create/upload flow defined yet — stub until that's scoped. - async function handleNewReport() { - console.log('New Report clicked — needs a create/upload flow defined'); + function handleNewReport() { + setIsUploadModalOpen(true); } @@ -367,6 +365,14 @@ function ReportsPageContent() { + {/* Upload New Report modal */} + setIsUploadModalOpen(false)} + onSuccess={() => { setIsUploadModalOpen(false); fetchReports(); }} + projects={projects} + /> + {/* Generate New Report modal — matches Figma */} { + const query = projectId ? `?projectId=${projectId}` : ''; + const res = await authedFetch<{ data: Report[] }>(`/reports${query}`); + return res.data ?? []; +} + +export async function getUploadUrl( + fileName: string, + projectId: number, +): Promise<{ uploadUrl: string; objectUrl: string }> { + return authedFetch(`/reports/upload-url?fileName=${encodeURIComponent(fileName)}&projectId=${projectId}`); +} + +export async function uploadFileToS3(uploadUrl: string, file: File): Promise { + const res = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + if (!res.ok) throw new Error('Failed to upload file to S3'); +} + +export async function createReport( + title: string, + projectId: number, + objectUrl: string, + reportType: 'technical' | 'narrative', +): Promise { + return authedFetch('/reports', { + method: 'POST', + body: JSON.stringify({ title, projectId, objectUrl, reportType }), + }); +} + +export async function uploadReport( + file: File, + title: string, + projectId: number, + reportType: 'technical' | 'narrative', +): Promise { + const { uploadUrl, objectUrl } = await getUploadUrl(file.name, projectId); + await uploadFileToS3(uploadUrl, file); + return createReport(title, projectId, objectUrl, reportType); +} diff --git a/apps/frontend/test/components/ReportsPage.test.tsx b/apps/frontend/test/components/ReportsPage.test.tsx index df5bec3..cdb107b 100644 --- a/apps/frontend/test/components/ReportsPage.test.tsx +++ b/apps/frontend/test/components/ReportsPage.test.tsx @@ -196,9 +196,8 @@ describe('ReportsPage', () => { }); }); - it('logs a stub message when New Report is clicked', async () => { + it('opens the Upload New Report modal when New Report is clicked', async () => { const user = userEvent.setup(); - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); mockApiFetchImplementation(); render(); @@ -206,11 +205,8 @@ describe('ReportsPage', () => { await user.click(screen.getByRole('button', { name: /new report/i })); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('New Report clicked'), - ); - - consoleSpy.mockRestore(); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Upload New Report')).toBeInTheDocument(); }); it('switches to the Schedule tab and shows the not-implemented message', async () => { diff --git a/apps/frontend/test/components/UploadReportModal.test.tsx b/apps/frontend/test/components/UploadReportModal.test.tsx new file mode 100644 index 0000000..4433875 --- /dev/null +++ b/apps/frontend/test/components/UploadReportModal.test.tsx @@ -0,0 +1,180 @@ +import { render, screen, fireEvent, waitFor } from '../utils'; +import UploadReportModal from '@/app/components/UploadReportModal'; +import * as reportsLib from '@/lib/reports'; + +jest.mock('../../src/lib/reports', () => ({ + uploadReport: jest.fn(), +})); + +jest.mock('../../src/app/components/DropdownSelector', () => { + return function MockDropdownSelector({ + options, + placeholder, + value, + onChange, + }: { + options: string[]; + placeholder?: string; + value?: string; + onChange?: (v: string) => void; + }) { + return ( + + ); + }; +}); + +const mockProjects = [ + { project_id: 1, name: 'Clinician Communication Study' }, + { project_id: 2, name: 'Health Education Initiative' }, +]; + +const baseProps = { + open: true, + onClose: jest.fn(), + onSuccess: jest.fn(), + projects: mockProjects, +}; + +function selectFile(name = 'report.pdf', type = 'application/pdf') { + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['content'], name, { type }); + fireEvent.change(input, { target: { files: [file] } }); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('UploadReportModal', () => { + it('renders the modal title when open', () => { + render(); + expect(screen.getByText('Upload New Report')).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render(); + expect(screen.queryByText('Upload New Report')).not.toBeInTheDocument(); + }); + + it('renders all field labels and action buttons', () => { + render(); + expect(screen.getByText('File* (PDF or DOCX)')).toBeInTheDocument(); + expect(screen.getByText('Title*')).toBeInTheDocument(); + expect(screen.getByText('Project*')).toBeInTheDocument(); + expect(screen.getByText('Report Type*')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /upload/i })).toBeInTheDocument(); + }); + + it('shows validation errors when submitting an empty form', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /upload/i })); + + expect(screen.getByText('Select a PDF or DOCX file')).toBeInTheDocument(); + expect(screen.getByText('Enter a title')).toBeInTheDocument(); + expect(screen.getByText('Select a project')).toBeInTheDocument(); + expect(screen.getByText('Select a report type')).toBeInTheDocument(); + }); + + it('does not call uploadReport when the form is invalid', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /upload/i })); + expect(reportsLib.uploadReport).not.toHaveBeenCalled(); + }); + + it('rejects unsupported file types', () => { + render(); + selectFile('image.png', 'image/png'); + fireEvent.click(screen.getByRole('button', { name: /upload/i })); + expect(screen.getByText('Select a PDF or DOCX file')).toBeInTheDocument(); + }); + + it('calls uploadReport with the correct args when the form is valid', async () => { + (reportsLib.uploadReport as jest.Mock).mockResolvedValueOnce({}); + render(); + + selectFile('report.pdf', 'application/pdf'); + fireEvent.change(screen.getByPlaceholderText('Enter report title'), { + target: { value: 'Q1 Report' }, + }); + fireEvent.change(screen.getByLabelText('Select a project'), { + target: { value: 'Clinician Communication Study' }, + }); + fireEvent.change(screen.getByLabelText('Select a report type'), { + target: { value: 'Technical' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /upload/i })); + + await waitFor(() => { + expect(reportsLib.uploadReport).toHaveBeenCalledWith( + expect.objectContaining({ name: 'report.pdf' }), + 'Q1 Report', + 1, + 'technical', + ); + }); + }); + + it('calls onSuccess after a successful upload', async () => { + (reportsLib.uploadReport as jest.Mock).mockResolvedValueOnce({}); + render(); + + selectFile(); + fireEvent.change(screen.getByPlaceholderText('Enter report title'), { + target: { value: 'Q1 Report' }, + }); + fireEvent.change(screen.getByLabelText('Select a project'), { + target: { value: 'Health Education Initiative' }, + }); + fireEvent.change(screen.getByLabelText('Select a report type'), { + target: { value: 'Narrative' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /upload/i })); + + await waitFor(() => { + expect(baseProps.onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('shows a submit error if uploadReport rejects', async () => { + (reportsLib.uploadReport as jest.Mock).mockRejectedValueOnce(new Error('S3 upload failed')); + render(); + + selectFile(); + fireEvent.change(screen.getByPlaceholderText('Enter report title'), { + target: { value: 'Q1 Report' }, + }); + fireEvent.change(screen.getByLabelText('Select a project'), { + target: { value: 'Clinician Communication Study' }, + }); + fireEvent.change(screen.getByLabelText('Select a report type'), { + target: { value: 'Technical' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /upload/i })); + + await waitFor(() => { + expect(screen.getByText('S3 upload failed')).toBeInTheDocument(); + }); + }); + + it('calls onClose when Cancel is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(baseProps.onClose).toHaveBeenCalledTimes(1); + }); +});