Skip to content

Commit 92f0231

Browse files
committed
feat(api,astro): ✨ Implement bulk request filing and TanStack Form migration
Implement bulk FOIA request creation API endpoint and integrate it into the frontend. Key changes: - **API:** Added `POST /requests/bulk` endpoint to create multiple requests simultaneously. - **API:** Auto-login user after successful registration, returning token/MFA status. - **API:** Added custom Hono validation hook for development logging. - **Astro:** Migrated `LoginForm`, `RegisterForm`, and `NewRequestForm` to use TanStack Form with Zod validation for improved state management and validation. - **Astro:** Replaced `AgencySearch` with `MultiAgencySelector` to support bulk filing. - **Astro:** Refactored API calls in `documents-page.tsx` to use the new `ApiClient` class. - **Astro:** Updated service worker to use `async/await` for `staleWhileRevalidate` strategy. - **Astro:** Updated Cypress test password to meet new complexity requirements.
1 parent ec450b0 commit 92f0231

File tree

22 files changed

+1842
-821
lines changed

22 files changed

+1842
-821
lines changed

apps/api/data/foia-stream.db-shm

0 Bytes
Binary file not shown.

apps/api/data/foia-stream.db-wal

0 Bytes
Binary file not shown.

apps/api/src/lib/create-app.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ import type { AppBindings, AppOpenAPI } from './types';
5050
export function createRouter(): OpenAPIHono<AppBindings> {
5151
return new OpenAPIHono<AppBindings>({
5252
strict: false,
53-
defaultHook,
53+
defaultHook: (result, c) => {
54+
// Custom hook: log validation errors in development
55+
if (!result.success) {
56+
console.error('[API Validation Error]', JSON.stringify(result.error.issues, null, 2));
57+
}
58+
// Call the original defaultHook behavior
59+
return defaultHook(result, c);
60+
},
5461
});
5562
}
5663

apps/api/src/routes/auth/auth.handlers.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const mapUserResponse = (user: Omit<User, 'passwordHash'>) => ({
6565
* Handler for POST /auth/register
6666
*
6767
* @param {Context} c - Hono context with validated request body
68-
* @returns {Promise<Response>} JSON response with created user or error
68+
* @returns {Promise<Response>} JSON response with created user and token or error
6969
* @compliance NIST 800-53 AC-2 (Account Management)
7070
* @compliance GDPR Article 7 (Conditions for consent)
7171
*/
@@ -89,10 +89,11 @@ export const register = async (c: Context) => {
8989
consents?: ConsentData;
9090
};
9191

92-
const { consents, ...userData } = data;
92+
const { consents, password, ...userData } = data;
9393
// Ensure role has a default value
9494
const userDataWithDefaults = {
9595
...userData,
96+
password,
9697
role: userData.role || ('civilian' as const),
9798
};
9899
const user = await authService.createUser(userDataWithDefaults);
@@ -105,10 +106,23 @@ export const register = async (c: Context) => {
105106
await consentService.recordRegistrationConsent(user.id, consents, ipAddress, userAgent);
106107
}
107108

109+
// Auto-login the user after registration
110+
const ipAddress = c.req.header('x-forwarded-for') || c.req.header('x-real-ip');
111+
const userAgent = c.req.header('user-agent');
112+
const loginResult = await authService.login(data.email, data.password, {
113+
ipAddress,
114+
userAgent,
115+
});
116+
108117
return c.json(
109118
{
110119
success: true,
111-
data: mapUserResponse(user),
120+
data: {
121+
token: loginResult.token,
122+
user: mapUserResponse(user),
123+
requiresMFA: loginResult.requiresMFA,
124+
mfaToken: loginResult.mfaToken,
125+
},
112126
message: 'Account created successfully',
113127
},
114128
201,

apps/api/src/routes/auth/auth.routes.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export const ConsentDataSchema = z
6060
termsAccepted: z.boolean().openapi({ example: true }),
6161
privacyAccepted: z.boolean().openapi({ example: true }),
6262
dataProcessingAccepted: z.boolean().openapi({ example: true }),
63-
consentTimestamp: z.string().datetime().openapi({ example: '2024-12-25T00:00:00.000Z' }),
63+
// Accept any ISO string format from frontend
64+
consentTimestamp: z.string().openapi({ example: '2024-12-25T00:00:00.000Z' }),
6465
})
6566
.openapi('ConsentData');
6667

@@ -329,10 +330,10 @@ export const registerRoute = createRoute({
329330
[HttpStatusCodes.CREATED]: {
330331
content: {
331332
'application/json': {
332-
schema: successResponse(UserResponseSchema, 'Account created successfully'),
333+
schema: successResponse(LoginResponseSchema, 'Account created successfully'),
333334
},
334335
},
335-
description: 'User created successfully',
336+
description: 'User created and logged in successfully',
336337
},
337338
[HttpStatusCodes.BAD_REQUEST]: {
338339
content: {

apps/api/src/routes/requests/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ router.openapi(routes.getDeadlinesRoute, handlers.getDeadlines);
6060
router.get('/requests/overdue', authMiddleware);
6161
router.openapi(routes.getOverdueRoute, handlers.getOverdue);
6262

63+
// Bulk create requests - must be before /requests/:id
64+
router.post('/requests/bulk', authMiddleware);
65+
router.openapi(routes.createBulkRequestRoute, handlers.createBulkRequests);
66+
6367
// ============================================
6468
// Optional Auth Route (GET /requests/:id)
6569
// Must be after specific routes like /requests/my

apps/api/src/routes/requests/requests.handlers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,46 @@ export const createRequest: AppRouteHandler<typeof createRequestRoute> = async (
212212
}
213213
};
214214

215+
/**
216+
* Bulk create FOIA requests handler
217+
* Creates multiple requests for different agencies with the same content
218+
*/
219+
export const createBulkRequests: AppRouteHandler<typeof createBulkRequestRoute> = async (c) => {
220+
try {
221+
const { userId } = c.get('user');
222+
const { agencyIds, ...requestData } = c.req.valid('json');
223+
224+
// Create requests for each agency
225+
const createdRequests = await Promise.all(
226+
agencyIds.map((agencyId: string) =>
227+
foiaRequestService.createRequest(userId, {
228+
agencyId,
229+
...requestData,
230+
}),
231+
),
232+
);
233+
234+
return c.json(
235+
{
236+
success: true as const,
237+
data: {
238+
createdRequests,
239+
totalCreated: createdRequests.length,
240+
},
241+
message: `Successfully created ${createdRequests.length} request${createdRequests.length > 1 ? 's' : ''}`,
242+
},
243+
HttpStatusCodes.CREATED,
244+
);
245+
} catch (error) {
246+
return handleRouteError(
247+
c,
248+
error,
249+
'Failed to create bulk requests',
250+
HttpStatusCodes.BAD_REQUEST,
251+
);
252+
}
253+
};
254+
215255
/**
216256
* Submit a draft request handler
217257
*/

apps/api/src/routes/requests/requests.routes.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,98 @@ export const createRequestRoute = createRoute({
484484
},
485485
});
486486

487+
/**
488+
* Bulk create request schema - for sending to multiple agencies
489+
*/
490+
const BulkCreateRequestSchema = z
491+
.object({
492+
agencyIds: z
493+
.array(z.string())
494+
.min(1, 'At least one agency is required')
495+
.max(20, 'Maximum 20 agencies per bulk request')
496+
.openapi({ example: ['agency-123', 'agency-456'] }),
497+
category: RecordCategorySchema.openapi({ example: 'body_cam_footage' }),
498+
title: z
499+
.string()
500+
.min(1)
501+
.max(200)
502+
.openapi({ example: 'Body camera footage from incident on Main St' }),
503+
description: z.string().min(1).openapi({
504+
example:
505+
'Requesting all body camera footage from officers responding to incident #12345 on January 15, 2024',
506+
}),
507+
dateRangeStart: z.string().optional().openapi({ example: '2024-01-15' }),
508+
dateRangeEnd: z.string().optional().openapi({ example: '2024-01-15' }),
509+
templateId: z.string().optional(),
510+
isPublic: z.boolean().default(false).openapi({ example: true }),
511+
})
512+
.openapi('BulkCreateRequest');
513+
514+
/**
515+
* Bulk create FOIA requests
516+
*/
517+
export const createBulkRequestRoute = createRoute({
518+
tags: ['Requests'],
519+
method: 'post',
520+
path: '/requests/bulk',
521+
summary: 'Bulk create FOIA requests',
522+
description:
523+
'Create multiple FOIA requests for different agencies with the same content. Useful for sending the same request to multiple agencies simultaneously.',
524+
security: [{ bearerAuth: [] }],
525+
request: {
526+
body: {
527+
required: true,
528+
description: 'Bulk FOIA request data with multiple agency IDs',
529+
content: {
530+
'application/json': {
531+
schema: BulkCreateRequestSchema,
532+
},
533+
},
534+
},
535+
},
536+
responses: {
537+
[HttpStatusCodes.CREATED]: {
538+
description: 'Requests created successfully',
539+
content: {
540+
'application/json': {
541+
schema: z.object({
542+
success: z.literal(true),
543+
data: z.object({
544+
createdRequests: z.array(FOIARequestSchema),
545+
totalCreated: z.number(),
546+
}),
547+
message: z.string().openapi({ type: 'string' }),
548+
}),
549+
},
550+
},
551+
},
552+
[HttpStatusCodes.BAD_REQUEST]: {
553+
description: 'Creation error',
554+
content: {
555+
'application/json': {
556+
schema: ErrorResponseSchema,
557+
},
558+
},
559+
},
560+
[HttpStatusCodes.UNAUTHORIZED]: {
561+
description: 'Not authenticated',
562+
content: {
563+
'application/json': {
564+
schema: ErrorResponseSchema,
565+
},
566+
},
567+
},
568+
[HttpStatusCodes.UNPROCESSABLE_ENTITY]: {
569+
description: 'Validation error',
570+
content: {
571+
'application/json': {
572+
schema: ErrorResponseSchema,
573+
},
574+
},
575+
},
576+
},
577+
});
578+
487579
/**
488580
* Submit a draft request
489581
*/
@@ -650,6 +742,7 @@ export const withdrawRequestRoute = createRoute({
650742

651743
// Export schemas
652744
export {
745+
BulkCreateRequestSchema,
653746
CreateRequestSchema,
654747
FOIARequestSchema,
655748
FOIARequestWithAgencySchema,

apps/astro/cypress/e2e/auth.cy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ describe('Authentication', () => {
105105
cy.get('input[name="lastName"]').should('not.be.disabled').type('User');
106106
cy.get('input[name="email"]').should('not.be.disabled').type('newuser@example.com');
107107
cy.get('input[name="organization"]').should('not.be.disabled').type('New Org');
108-
cy.get('input[name="password"]').should('not.be.disabled').type('password123');
109-
cy.get('input[name="confirmPassword"]').should('not.be.disabled').type('password123');
108+
cy.get('input[name="password"]').should('not.be.disabled').type('Password123');
109+
cy.get('input[name="confirmPassword"]').should('not.be.disabled').type('Password123');
110110

111111
// Handle Terms of Service
112112
cy.contains('button', 'Read & Accept').first().click(); // Opens Terms modal

apps/astro/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@effect/platform": "^0.82.8",
2626
"@foia-stream/shared": "workspace:*",
2727
"@nanostores/react": "^0.8.4",
28+
"@tanstack/react-form": "^1.27.6",
2829
"@tanstack/react-query": "^5.90.12",
2930
"@tanstack/react-query-devtools": "^5.91.1",
3031
"astro": "^5.8.0",
@@ -36,6 +37,7 @@
3637
"react": "^19.1.0",
3738
"react-dom": "^19.1.0",
3839
"tailwind-merge": "^3.3.0",
40+
"zod": "^4.2.1",
3941
"zustand": "^5.0.9"
4042
},
4143
"devDependencies": {

0 commit comments

Comments
 (0)