@voyant-travel/mice 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/routes.js CHANGED
@@ -1,117 +1,546 @@
1
1
  /**
2
2
  * MICE program admin routes. Mounted by the deployment under `/v1/admin/mice`.
3
- * Routes stay thin: validate, call `miceService`, serialize. See RFC voyant#1489.
3
+ * Routes stay thin: validate, call the domain services, serialize. See RFC
4
+ * voyant#1489.
5
+ *
6
+ * Migrated to `@hono/zod-openapi` for the OpenAPI admin backfill (voyant#2114 —
7
+ * mice sub-batch). Request schemas reuse the exported `validation*.ts` schemas
8
+ * the handlers already parse; response row schemas are authored here from the
9
+ * Drizzle `$inferSelect` shapes (§17: `date`/timestamp columns serialize to
10
+ * ISO strings over the wire; integer fields stay numbers). The MICE list
11
+ * services return `{ data, limit, offset }` (no `total` count read), so the
12
+ * list envelope is declared locally rather than via `listResponseSchema`. The
13
+ * single-resource GET legs return the base row extended with the joined child
14
+ * collections (`inclusions`, `enrollments`, `delegates`, `invitations`/`bids`,
15
+ * `lines`/`evaluations`). Typed service-outcome → HTTP-status unions are
16
+ * declared inline per leg.
17
+ *
18
+ * Each resource is its own small `OpenAPIHono` sub-chain composed onto
19
+ * `miceAdminRoutes` via `.route("/", child)` so the `.openapi()` operations
20
+ * propagate up while keeping type-inference cost bounded (one flat chain has
21
+ * O(n²) inference cost). Within each child, static/collection paths are
22
+ * registered before dynamic `/{id}` legs.
4
23
  */
5
- import { parseJsonBody, parseQuery } from "@voyant-travel/hono";
6
- import { Hono } from "hono";
24
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
25
+ import { openApiValidationHook } from "@voyant-travel/hono";
7
26
  import { createProgram, getProgram, listPrograms, updateProgram } from "./service.js";
8
27
  import { commercialsService } from "./service-commercials.js";
9
28
  import { createDelegate, enrollDelegate, getDelegate, listDelegates, updateDelegate, } from "./service-delegates.js";
10
29
  import { rfpService } from "./service-rfp.js";
11
30
  import { createRoomingAssignment, getRoomingAssignment, listRoomingAssignments, setRoomingDelegates, updateRoomingAssignment, } from "./service-rooming.js";
12
31
  import { createSession, deleteSession, getSession, listSessions, setSessionInclusions, updateSession, } from "./service-sessions.js";
13
- import { createProgramSchema, programListQuerySchema, updateProgramSchema } from "./validation.js";
14
- import { createDelegateSchema, delegateListQuerySchema, enrollDelegateSchema, updateDelegateSchema, } from "./validation-delegates.js";
15
- import { addBidEvaluationSchema, awardRfpSchema, createBidSchema, createRfpSchema, inviteSupplierSchema, rfpListQuerySchema, setBidLinesSchema, updateBidSchema, updateRfpSchema, } from "./validation-rfp.js";
32
+ import { createProgramSchema, programListQuerySchema, programStatusSchema, programTypeSchema, updateProgramSchema, } from "./validation.js";
33
+ import { createDelegateSchema, delegateListQuerySchema, delegateRoleSchema, delegateStatusSchema, enrollDelegateSchema, enrollmentStatusSchema, updateDelegateSchema, } from "./validation-delegates.js";
34
+ import { addBidEvaluationSchema, awardRfpSchema, bidStatusSchema, createBidSchema, createRfpSchema, inviteSupplierSchema, rfpListQuerySchema, rfpStatusSchema, setBidLinesSchema, updateBidSchema, updateRfpSchema, } from "./validation-rfp.js";
16
35
  import { createRoomingAssignmentSchema, roomingListQuerySchema, setRoomingDelegatesSchema, updateRoomingAssignmentSchema, } from "./validation-rooming.js";
17
- import { createSessionSchema, sessionListQuerySchema, setSessionInclusionsSchema, updateSessionSchema, } from "./validation-sessions.js";
18
- export const miceAdminRoutes = new Hono()
19
- .get("/programs", async (c) => {
20
- const query = await parseQuery(c, programListQuerySchema);
21
- return c.json(await listPrograms(c.get("db"), query));
22
- })
23
- .post("/programs", async (c) => {
24
- const body = await parseJsonBody(c, createProgramSchema);
25
- return c.json({ data: await createProgram(c.get("db"), body) }, 201);
26
- })
27
- .get("/programs/:id", async (c) => {
28
- const program = await getProgram(c.get("db"), c.req.param("id"));
29
- if (!program)
30
- return c.json({ error: "Program not found" }, 404);
31
- return c.json({ data: program });
32
- })
33
- // Consolidated commercials program cost sheet / P&L (Phase 5).
34
- .get("/programs/:id/cost-sheet", async (c) => {
35
- const id = c.req.param("id");
36
+ import { createSessionSchema, sessionInclusionKindSchema, sessionListQuerySchema, sessionTypeSchema, setSessionInclusionsSchema, updateSessionSchema, } from "./validation-sessions.js";
37
+ // --- shared response building blocks ----------------------------------------
38
+ const errorResponseSchema = z.object({ error: z.string() });
39
+ const successResponseSchema = z.object({ success: z.literal(true) });
40
+ const idParamSchema = z.object({ id: z.string() });
41
+ // §17: timestamp/date columns serialize to ISO strings on the wire.
42
+ const isoTimestamp = z.string();
43
+ const isoDate = z.string();
44
+ const jsonRecord = z.record(z.string(), z.unknown());
45
+ /** One `application/json` "invalid request body" response entry. */
46
+ const invalidRequestResponse = {
47
+ description: "invalid_request: request body failed validation",
48
+ content: { "application/json": { schema: errorResponseSchema } },
49
+ };
50
+ const notFoundResponse = (description) => ({
51
+ description,
52
+ content: { "application/json": { schema: errorResponseSchema } },
53
+ });
54
+ /** Local list envelope — MICE lists return `{ data, limit, offset }` (no total). */
55
+ const listEnvelope = (row) => z.object({
56
+ data: z.array(row),
57
+ limit: z.number().int(),
58
+ offset: z.number().int(),
59
+ });
60
+ const dataEnvelope = (row) => z.object({ data: row });
61
+ // --- response row schemas (authored from the Drizzle `$inferSelect` shapes) --
62
+ const programSchema = z.object({
63
+ id: z.string(),
64
+ organizationId: z.string().nullable(),
65
+ primaryContactPersonId: z.string().nullable(),
66
+ accountManagerId: z.string().nullable(),
67
+ name: z.string(),
68
+ code: z.string().nullable(),
69
+ type: programTypeSchema,
70
+ status: programStatusSchema,
71
+ destination: z.string().nullable(),
72
+ startDate: isoDate.nullable(),
73
+ endDate: isoDate.nullable(),
74
+ estimatedPax: z.number().int().nullable(),
75
+ confirmedPax: z.number().int().nullable(),
76
+ currency: z.string().nullable(),
77
+ budgetAmountCents: z.number().int().nullable(),
78
+ metadata: jsonRecord.nullable(),
79
+ createdAt: isoTimestamp,
80
+ updatedAt: isoTimestamp,
81
+ });
82
+ const sessionSchema = z.object({
83
+ id: z.string(),
84
+ programId: z.string(),
85
+ functionSpaceId: z.string().nullable(),
86
+ title: z.string(),
87
+ sessionType: sessionTypeSchema,
88
+ dayDate: isoDate.nullable(),
89
+ startsAt: isoTimestamp.nullable(),
90
+ endsAt: isoTimestamp.nullable(),
91
+ track: z.string().nullable(),
92
+ capacity: z.number().int().nullable(),
93
+ requiresRegistration: z.boolean(),
94
+ notes: z.string().nullable(),
95
+ metadata: jsonRecord.nullable(),
96
+ createdAt: isoTimestamp,
97
+ updatedAt: isoTimestamp,
98
+ });
99
+ const sessionInclusionSchema = z.object({
100
+ id: z.string(),
101
+ sessionId: z.string(),
102
+ kind: sessionInclusionKindSchema,
103
+ description: z.string().nullable(),
104
+ quantity: z.number().int(),
105
+ costAmountCents: z.number().int().nullable(),
106
+ currency: z.string().nullable(),
107
+ createdAt: isoTimestamp,
108
+ updatedAt: isoTimestamp,
109
+ });
110
+ const sessionWithInclusionsSchema = sessionSchema.extend({
111
+ inclusions: z.array(sessionInclusionSchema),
112
+ });
113
+ const delegateSchema = z.object({
114
+ id: z.string(),
115
+ programId: z.string(),
116
+ personId: z.string().nullable(),
117
+ bookingId: z.string().nullable(),
118
+ role: delegateRoleSchema,
119
+ status: delegateStatusSchema,
120
+ arrivalAt: isoTimestamp.nullable(),
121
+ departureAt: isoTimestamp.nullable(),
122
+ notes: z.string().nullable(),
123
+ metadata: jsonRecord.nullable(),
124
+ createdAt: isoTimestamp,
125
+ updatedAt: isoTimestamp,
126
+ });
127
+ const enrollmentSchema = z.object({
128
+ id: z.string(),
129
+ delegateId: z.string(),
130
+ sessionId: z.string(),
131
+ status: enrollmentStatusSchema,
132
+ createdAt: isoTimestamp,
133
+ updatedAt: isoTimestamp,
134
+ });
135
+ const delegateWithEnrollmentsSchema = delegateSchema.extend({
136
+ enrollments: z.array(enrollmentSchema),
137
+ });
138
+ const roomingAssignmentSchema = z.object({
139
+ id: z.string(),
140
+ programId: z.string(),
141
+ roomBlockId: z.string().nullable(),
142
+ roomTypeId: z.string().nullable(),
143
+ bedConfig: z.string().nullable(),
144
+ sharingGroupId: z.string().nullable(),
145
+ checkIn: isoDate.nullable(),
146
+ checkOut: isoDate.nullable(),
147
+ specialRequests: z.string().nullable(),
148
+ metadata: jsonRecord.nullable(),
149
+ createdAt: isoTimestamp,
150
+ updatedAt: isoTimestamp,
151
+ });
152
+ const roomingAssignmentDelegateSchema = z.object({
153
+ id: z.string(),
154
+ roomingAssignmentId: z.string(),
155
+ delegateId: z.string(),
156
+ isPrimary: z.boolean(),
157
+ bedLabel: z.string().nullable(),
158
+ createdAt: isoTimestamp,
159
+ });
160
+ const roomingWithDelegatesSchema = roomingAssignmentSchema.extend({
161
+ delegates: z.array(roomingAssignmentDelegateSchema),
162
+ });
163
+ const rfpInvitationStatusSchema = z.enum(["invited", "viewed", "declined", "responded"]);
164
+ const rfpSchema = z.object({
165
+ id: z.string(),
166
+ programId: z.string(),
167
+ title: z.string(),
168
+ requirements: jsonRecord.nullable(),
169
+ status: rfpStatusSchema,
170
+ issuedAt: isoTimestamp.nullable(),
171
+ dueAt: isoTimestamp.nullable(),
172
+ notes: z.string().nullable(),
173
+ createdAt: isoTimestamp,
174
+ updatedAt: isoTimestamp,
175
+ });
176
+ const rfpInvitationSchema = z.object({
177
+ id: z.string(),
178
+ rfpId: z.string(),
179
+ supplierId: z.string(),
180
+ status: rfpInvitationStatusSchema,
181
+ createdAt: isoTimestamp,
182
+ updatedAt: isoTimestamp,
183
+ });
184
+ const bidSchema = z.object({
185
+ id: z.string(),
186
+ rfpId: z.string(),
187
+ supplierId: z.string(),
188
+ status: bidStatusSchema,
189
+ totalCents: z.number().int().nullable(),
190
+ currency: z.string().nullable(),
191
+ proposalDoc: z.string().nullable(),
192
+ validUntil: isoTimestamp.nullable(),
193
+ notes: z.string().nullable(),
194
+ createdAt: isoTimestamp,
195
+ updatedAt: isoTimestamp,
196
+ });
197
+ const bidLineSchema = z.object({
198
+ id: z.string(),
199
+ bidId: z.string(),
200
+ requirementRef: z.string().nullable(),
201
+ description: z.string().nullable(),
202
+ quantity: z.number().int(),
203
+ unitCents: z.number().int().nullable(),
204
+ totalCents: z.number().int().nullable(),
205
+ createdAt: isoTimestamp,
206
+ });
207
+ const bidEvaluationSchema = z.object({
208
+ id: z.string(),
209
+ bidId: z.string(),
210
+ criterion: z.string(),
211
+ weight: z.number().int().nullable(),
212
+ score: z.number().int().nullable(),
213
+ notes: z.string().nullable(),
214
+ evaluatedBy: z.string().nullable(),
215
+ createdAt: isoTimestamp,
216
+ });
217
+ const rfpWithDetailsSchema = rfpSchema.extend({
218
+ invitations: z.array(rfpInvitationSchema),
219
+ bids: z.array(bidSchema),
220
+ });
221
+ const bidWithDetailsSchema = bidSchema.extend({
222
+ lines: z.array(bidLineSchema),
223
+ evaluations: z.array(bidEvaluationSchema),
224
+ });
225
+ // Program commercials — read-model cost sheet (no spine table).
226
+ const costSheetCategorySchema = z.object({
227
+ contractedCostCents: z.number().int(),
228
+ pickedCostCents: z.number().int(),
229
+ pickedSellCents: z.number().int(),
230
+ });
231
+ const costSheetCurrencyTotalsSchema = z.object({
232
+ currency: z.string(),
233
+ roomBlocks: costSheetCategorySchema,
234
+ spaceBlocks: costSheetCategorySchema,
235
+ sessionInclusionsCostCents: z.number().int(),
236
+ costCents: z.number().int(),
237
+ sellCents: z.number().int(),
238
+ marginCents: z.number().int(),
239
+ marginPct: z.number().nullable(),
240
+ });
241
+ const programCostSheetSchema = z.object({
242
+ programId: z.string(),
243
+ mixedCurrency: z.boolean(),
244
+ byCurrency: z.array(costSheetCurrencyTotalsSchema),
245
+ });
246
+ // === programs ===============================================================
247
+ const listProgramsRoute = createRoute({
248
+ method: "get",
249
+ path: "/programs",
250
+ request: { query: programListQuerySchema },
251
+ responses: {
252
+ 200: {
253
+ description: "Paginated MICE programs",
254
+ content: { "application/json": { schema: listEnvelope(programSchema) } },
255
+ },
256
+ },
257
+ });
258
+ const createProgramRoute = createRoute({
259
+ method: "post",
260
+ path: "/programs",
261
+ request: {
262
+ body: { required: true, content: { "application/json": { schema: createProgramSchema } } },
263
+ },
264
+ responses: {
265
+ 201: {
266
+ description: "The created program",
267
+ content: { "application/json": { schema: dataEnvelope(programSchema) } },
268
+ },
269
+ 400: invalidRequestResponse,
270
+ },
271
+ });
272
+ const getProgramRoute = createRoute({
273
+ method: "get",
274
+ path: "/programs/{id}",
275
+ request: { params: idParamSchema },
276
+ responses: {
277
+ 200: {
278
+ description: "A program by id",
279
+ content: { "application/json": { schema: dataEnvelope(programSchema) } },
280
+ },
281
+ 404: notFoundResponse("Program not found"),
282
+ },
283
+ });
284
+ const getProgramCostSheetRoute = createRoute({
285
+ method: "get",
286
+ path: "/programs/{id}/cost-sheet",
287
+ request: { params: idParamSchema },
288
+ responses: {
289
+ 200: {
290
+ description: "The program cost sheet / P&L grouped by currency",
291
+ content: { "application/json": { schema: dataEnvelope(programCostSheetSchema) } },
292
+ },
293
+ 404: notFoundResponse("Program not found"),
294
+ },
295
+ });
296
+ const updateProgramRoute = createRoute({
297
+ method: "patch",
298
+ path: "/programs/{id}",
299
+ request: {
300
+ params: idParamSchema,
301
+ body: { required: true, content: { "application/json": { schema: updateProgramSchema } } },
302
+ },
303
+ responses: {
304
+ 200: {
305
+ description: "The updated program",
306
+ content: { "application/json": { schema: dataEnvelope(programSchema) } },
307
+ },
308
+ 400: invalidRequestResponse,
309
+ 404: notFoundResponse("Program not found"),
310
+ },
311
+ });
312
+ const programRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
313
+ .openapi(listProgramsRoute, async (c) => c.json(await listPrograms(c.get("db"), c.req.valid("query")), 200))
314
+ .openapi(createProgramRoute, async (c) => c.json({ data: await createProgram(c.get("db"), c.req.valid("json")) }, 201))
315
+ // static sub-resource before the dynamic `/{id}` catch-all
316
+ .openapi(getProgramCostSheetRoute, async (c) => {
317
+ const id = c.req.valid("param").id;
36
318
  const program = await getProgram(c.get("db"), id);
37
319
  if (!program)
38
320
  return c.json({ error: "Program not found" }, 404);
39
- return c.json({ data: await commercialsService.getProgramCostSheet(c.get("db"), id) });
40
- })
41
- .patch("/programs/:id", async (c) => {
42
- const body = await parseJsonBody(c, updateProgramSchema);
43
- const program = await updateProgram(c.get("db"), c.req.param("id"), body);
44
- if (!program)
45
- return c.json({ error: "Program not found" }, 404);
46
- return c.json({ data: program });
321
+ return c.json({ data: await commercialsService.getProgramCostSheet(c.get("db"), id) }, 200);
47
322
  })
48
- // Agenda sessions (RFC voyant#1489 Phase 2).
49
- .get("/sessions", async (c) => {
50
- const query = await parseQuery(c, sessionListQuerySchema);
51
- return c.json(await listSessions(c.get("db"), query));
323
+ .openapi(getProgramRoute, async (c) => {
324
+ const program = await getProgram(c.get("db"), c.req.valid("param").id);
325
+ return program ? c.json({ data: program }, 200) : c.json({ error: "Program not found" }, 404);
52
326
  })
53
- .post("/sessions", async (c) => {
54
- const body = await parseJsonBody(c, createSessionSchema);
55
- const outcome = await createSession(c.get("db"), body);
327
+ .openapi(updateProgramRoute, async (c) => {
328
+ const program = await updateProgram(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
329
+ return program ? c.json({ data: program }, 200) : c.json({ error: "Program not found" }, 404);
330
+ });
331
+ // === sessions ===============================================================
332
+ const listSessionsRoute = createRoute({
333
+ method: "get",
334
+ path: "/sessions",
335
+ request: { query: sessionListQuerySchema },
336
+ responses: {
337
+ 200: {
338
+ description: "Paginated agenda sessions",
339
+ content: { "application/json": { schema: listEnvelope(sessionSchema) } },
340
+ },
341
+ },
342
+ });
343
+ const createSessionRoute = createRoute({
344
+ method: "post",
345
+ path: "/sessions",
346
+ request: {
347
+ body: { required: true, content: { "application/json": { schema: createSessionSchema } } },
348
+ },
349
+ responses: {
350
+ 201: {
351
+ description: "The created session",
352
+ content: { "application/json": { schema: dataEnvelope(sessionSchema) } },
353
+ },
354
+ 400: invalidRequestResponse,
355
+ 404: notFoundResponse("Program not found"),
356
+ },
357
+ });
358
+ const getSessionRoute = createRoute({
359
+ method: "get",
360
+ path: "/sessions/{id}",
361
+ request: { params: idParamSchema },
362
+ responses: {
363
+ 200: {
364
+ description: "A session by id with its inclusions",
365
+ content: { "application/json": { schema: dataEnvelope(sessionWithInclusionsSchema) } },
366
+ },
367
+ 404: notFoundResponse("Session not found"),
368
+ },
369
+ });
370
+ const updateSessionRoute = createRoute({
371
+ method: "patch",
372
+ path: "/sessions/{id}",
373
+ request: {
374
+ params: idParamSchema,
375
+ body: { required: true, content: { "application/json": { schema: updateSessionSchema } } },
376
+ },
377
+ responses: {
378
+ 200: {
379
+ description: "The updated session",
380
+ content: { "application/json": { schema: dataEnvelope(sessionSchema) } },
381
+ },
382
+ 400: invalidRequestResponse,
383
+ 404: notFoundResponse("Session not found"),
384
+ },
385
+ });
386
+ const deleteSessionRoute = createRoute({
387
+ method: "delete",
388
+ path: "/sessions/{id}",
389
+ request: { params: idParamSchema },
390
+ responses: {
391
+ 200: {
392
+ description: "Session deleted",
393
+ content: { "application/json": { schema: successResponseSchema } },
394
+ },
395
+ 404: notFoundResponse("Session not found"),
396
+ },
397
+ });
398
+ const setSessionInclusionsRoute = createRoute({
399
+ method: "put",
400
+ path: "/sessions/{id}/inclusions",
401
+ request: {
402
+ params: idParamSchema,
403
+ body: {
404
+ required: true,
405
+ content: { "application/json": { schema: setSessionInclusionsSchema } },
406
+ },
407
+ },
408
+ responses: {
409
+ 200: {
410
+ description: "The replaced session inclusions",
411
+ content: { "application/json": { schema: dataEnvelope(z.array(sessionInclusionSchema)) } },
412
+ },
413
+ 400: invalidRequestResponse,
414
+ 404: notFoundResponse("Session not found"),
415
+ },
416
+ });
417
+ const sessionRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
418
+ .openapi(listSessionsRoute, async (c) => c.json(await listSessions(c.get("db"), c.req.valid("query")), 200))
419
+ .openapi(createSessionRoute, async (c) => {
420
+ const outcome = await createSession(c.get("db"), c.req.valid("json"));
56
421
  if (outcome.status === "program_not_found")
57
422
  return c.json({ error: "Program not found" }, 404);
58
423
  return c.json({ data: outcome.session }, 201);
59
424
  })
60
- .get("/sessions/:id", async (c) => {
61
- const session = await getSession(c.get("db"), c.req.param("id"));
62
- if (!session)
63
- return c.json({ error: "Session not found" }, 404);
64
- return c.json({ data: session });
425
+ .openapi(getSessionRoute, async (c) => {
426
+ const session = await getSession(c.get("db"), c.req.valid("param").id);
427
+ return session ? c.json({ data: session }, 200) : c.json({ error: "Session not found" }, 404);
65
428
  })
66
- .patch("/sessions/:id", async (c) => {
67
- const body = await parseJsonBody(c, updateSessionSchema);
68
- const session = await updateSession(c.get("db"), c.req.param("id"), body);
69
- if (!session)
70
- return c.json({ error: "Session not found" }, 404);
71
- return c.json({ data: session });
429
+ .openapi(updateSessionRoute, async (c) => {
430
+ const session = await updateSession(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
431
+ return session ? c.json({ data: session }, 200) : c.json({ error: "Session not found" }, 404);
72
432
  })
73
- .delete("/sessions/:id", async (c) => {
74
- const ok = await deleteSession(c.get("db"), c.req.param("id"));
75
- if (!ok)
76
- return c.json({ error: "Session not found" }, 404);
77
- return c.json({ success: true });
433
+ .openapi(deleteSessionRoute, async (c) => {
434
+ const ok = await deleteSession(c.get("db"), c.req.valid("param").id);
435
+ return ok
436
+ ? c.json({ success: true }, 200)
437
+ : c.json({ error: "Session not found" }, 404);
78
438
  })
79
- .put("/sessions/:id/inclusions", async (c) => {
80
- const id = c.req.param("id");
439
+ .openapi(setSessionInclusionsRoute, async (c) => {
440
+ const id = c.req.valid("param").id;
81
441
  const session = await getSession(c.get("db"), id);
82
442
  if (!session)
83
443
  return c.json({ error: "Session not found" }, 404);
84
- const { inclusions } = await parseJsonBody(c, setSessionInclusionsSchema);
85
- return c.json({ data: await setSessionInclusions(c.get("db"), id, inclusions) });
86
- })
87
- // Delegates + session enrollment (RFC voyant#1489 Phase 3).
88
- .get("/delegates", async (c) => {
89
- const query = await parseQuery(c, delegateListQuerySchema);
90
- return c.json(await listDelegates(c.get("db"), query));
91
- })
92
- .post("/delegates", async (c) => {
93
- const body = await parseJsonBody(c, createDelegateSchema);
94
- const outcome = await createDelegate(c.get("db"), body);
444
+ const { inclusions } = c.req.valid("json");
445
+ return c.json({ data: await setSessionInclusions(c.get("db"), id, inclusions) }, 200);
446
+ });
447
+ // === delegates ==============================================================
448
+ const listDelegatesRoute = createRoute({
449
+ method: "get",
450
+ path: "/delegates",
451
+ request: { query: delegateListQuerySchema },
452
+ responses: {
453
+ 200: {
454
+ description: "Paginated program delegates",
455
+ content: { "application/json": { schema: listEnvelope(delegateSchema) } },
456
+ },
457
+ },
458
+ });
459
+ const createDelegateRoute = createRoute({
460
+ method: "post",
461
+ path: "/delegates",
462
+ request: {
463
+ body: { required: true, content: { "application/json": { schema: createDelegateSchema } } },
464
+ },
465
+ responses: {
466
+ 201: {
467
+ description: "The created delegate",
468
+ content: { "application/json": { schema: dataEnvelope(delegateSchema) } },
469
+ },
470
+ 400: invalidRequestResponse,
471
+ 404: notFoundResponse("Program not found"),
472
+ },
473
+ });
474
+ const getDelegateRoute = createRoute({
475
+ method: "get",
476
+ path: "/delegates/{id}",
477
+ request: { params: idParamSchema },
478
+ responses: {
479
+ 200: {
480
+ description: "A delegate by id with its session enrollments",
481
+ content: { "application/json": { schema: dataEnvelope(delegateWithEnrollmentsSchema) } },
482
+ },
483
+ 404: notFoundResponse("Delegate not found"),
484
+ },
485
+ });
486
+ const updateDelegateRoute = createRoute({
487
+ method: "patch",
488
+ path: "/delegates/{id}",
489
+ request: {
490
+ params: idParamSchema,
491
+ body: { required: true, content: { "application/json": { schema: updateDelegateSchema } } },
492
+ },
493
+ responses: {
494
+ 200: {
495
+ description: "The updated delegate",
496
+ content: { "application/json": { schema: dataEnvelope(delegateSchema) } },
497
+ },
498
+ 400: invalidRequestResponse,
499
+ 404: notFoundResponse("Delegate not found"),
500
+ },
501
+ });
502
+ const enrollDelegateRoute = createRoute({
503
+ method: "post",
504
+ path: "/delegates/{id}/enrollments",
505
+ request: {
506
+ params: idParamSchema,
507
+ body: { required: true, content: { "application/json": { schema: enrollDelegateSchema } } },
508
+ },
509
+ responses: {
510
+ 200: {
511
+ description: "The existing enrollment (idempotent)",
512
+ content: { "application/json": { schema: dataEnvelope(enrollmentSchema) } },
513
+ },
514
+ 201: {
515
+ description: "The created enrollment",
516
+ content: { "application/json": { schema: dataEnvelope(enrollmentSchema) } },
517
+ },
518
+ 400: invalidRequestResponse,
519
+ 404: notFoundResponse("Delegate or session not found"),
520
+ 409: {
521
+ description: "Session belongs to a different program",
522
+ content: { "application/json": { schema: errorResponseSchema } },
523
+ },
524
+ },
525
+ });
526
+ const delegateRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
527
+ .openapi(listDelegatesRoute, async (c) => c.json(await listDelegates(c.get("db"), c.req.valid("query")), 200))
528
+ .openapi(createDelegateRoute, async (c) => {
529
+ const outcome = await createDelegate(c.get("db"), c.req.valid("json"));
95
530
  if (outcome.status === "program_not_found")
96
531
  return c.json({ error: "Program not found" }, 404);
97
532
  return c.json({ data: outcome.delegate }, 201);
98
533
  })
99
- .get("/delegates/:id", async (c) => {
100
- const delegate = await getDelegate(c.get("db"), c.req.param("id"));
101
- if (!delegate)
102
- return c.json({ error: "Delegate not found" }, 404);
103
- return c.json({ data: delegate });
104
- })
105
- .patch("/delegates/:id", async (c) => {
106
- const body = await parseJsonBody(c, updateDelegateSchema);
107
- const delegate = await updateDelegate(c.get("db"), c.req.param("id"), body);
108
- if (!delegate)
109
- return c.json({ error: "Delegate not found" }, 404);
110
- return c.json({ data: delegate });
111
- })
112
- .post("/delegates/:id/enrollments", async (c) => {
113
- const body = await parseJsonBody(c, enrollDelegateSchema);
114
- const outcome = await enrollDelegate(c.get("db"), c.req.param("id"), body);
534
+ .openapi(getDelegateRoute, async (c) => {
535
+ const delegate = await getDelegate(c.get("db"), c.req.valid("param").id);
536
+ return delegate ? c.json({ data: delegate }, 200) : c.json({ error: "Delegate not found" }, 404);
537
+ })
538
+ .openapi(updateDelegateRoute, async (c) => {
539
+ const delegate = await updateDelegate(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
540
+ return delegate ? c.json({ data: delegate }, 200) : c.json({ error: "Delegate not found" }, 404);
541
+ })
542
+ .openapi(enrollDelegateRoute, async (c) => {
543
+ const outcome = await enrollDelegate(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
115
544
  switch (outcome.status) {
116
545
  case "ok":
117
546
  return c.json({ data: outcome.enrollment }, outcome.idempotent ? 200 : 201);
@@ -122,38 +551,136 @@ export const miceAdminRoutes = new Hono()
122
551
  case "program_mismatch":
123
552
  return c.json({ error: "Session belongs to a different program" }, 409);
124
553
  }
125
- })
126
- // Rooming manifest (RFC voyant#1489 Phase 3).
127
- .get("/rooming-assignments", async (c) => {
128
- const query = await parseQuery(c, roomingListQuerySchema);
129
- return c.json(await listRoomingAssignments(c.get("db"), query));
130
- })
131
- .post("/rooming-assignments", async (c) => {
132
- const body = await parseJsonBody(c, createRoomingAssignmentSchema);
133
- const outcome = await createRoomingAssignment(c.get("db"), body);
554
+ });
555
+ // === rooming assignments ====================================================
556
+ const listRoomingAssignmentsRoute = createRoute({
557
+ method: "get",
558
+ path: "/rooming-assignments",
559
+ request: { query: roomingListQuerySchema },
560
+ responses: {
561
+ 200: {
562
+ description: "Paginated rooming assignments",
563
+ content: { "application/json": { schema: listEnvelope(roomingAssignmentSchema) } },
564
+ },
565
+ },
566
+ });
567
+ const createRoomingAssignmentRoute = createRoute({
568
+ method: "post",
569
+ path: "/rooming-assignments",
570
+ request: {
571
+ body: {
572
+ required: true,
573
+ content: { "application/json": { schema: createRoomingAssignmentSchema } },
574
+ },
575
+ },
576
+ responses: {
577
+ 201: {
578
+ description: "The created rooming assignment",
579
+ content: { "application/json": { schema: dataEnvelope(roomingAssignmentSchema) } },
580
+ },
581
+ 400: invalidRequestResponse,
582
+ 404: notFoundResponse("Program not found"),
583
+ },
584
+ });
585
+ const getRoomingAssignmentRoute = createRoute({
586
+ method: "get",
587
+ path: "/rooming-assignments/{id}",
588
+ request: { params: idParamSchema },
589
+ responses: {
590
+ 200: {
591
+ description: "A rooming assignment by id with its delegates",
592
+ content: { "application/json": { schema: dataEnvelope(roomingWithDelegatesSchema) } },
593
+ },
594
+ 404: notFoundResponse("Rooming assignment not found"),
595
+ },
596
+ });
597
+ const updateRoomingAssignmentRoute = createRoute({
598
+ method: "patch",
599
+ path: "/rooming-assignments/{id}",
600
+ request: {
601
+ params: idParamSchema,
602
+ body: {
603
+ required: true,
604
+ content: { "application/json": { schema: updateRoomingAssignmentSchema } },
605
+ },
606
+ },
607
+ responses: {
608
+ 200: {
609
+ description: "The updated rooming assignment",
610
+ content: { "application/json": { schema: dataEnvelope(roomingAssignmentSchema) } },
611
+ },
612
+ 400: invalidRequestResponse,
613
+ 404: notFoundResponse("Rooming assignment not found"),
614
+ },
615
+ });
616
+ const setRoomingDelegatesRoute = createRoute({
617
+ method: "put",
618
+ path: "/rooming-assignments/{id}/delegates",
619
+ request: {
620
+ params: idParamSchema,
621
+ body: {
622
+ required: true,
623
+ content: { "application/json": { schema: setRoomingDelegatesSchema } },
624
+ },
625
+ },
626
+ responses: {
627
+ 200: {
628
+ description: "The replaced rooming-assignment occupants",
629
+ content: {
630
+ "application/json": { schema: dataEnvelope(z.array(roomingAssignmentDelegateSchema)) },
631
+ },
632
+ },
633
+ 400: invalidRequestResponse,
634
+ 404: {
635
+ description: "Rooming assignment not found, or one or more delegates not found",
636
+ content: {
637
+ "application/json": {
638
+ schema: z.object({
639
+ error: z.string(),
640
+ detail: z.object({ missing: z.array(z.string()) }).optional(),
641
+ }),
642
+ },
643
+ },
644
+ },
645
+ 409: {
646
+ description: "One or more delegates belong to a different program",
647
+ content: {
648
+ "application/json": {
649
+ schema: z.object({
650
+ error: z.string(),
651
+ detail: z.object({ offending: z.array(z.string()) }).optional(),
652
+ }),
653
+ },
654
+ },
655
+ },
656
+ },
657
+ });
658
+ const roomingRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
659
+ .openapi(listRoomingAssignmentsRoute, async (c) => c.json(await listRoomingAssignments(c.get("db"), c.req.valid("query")), 200))
660
+ .openapi(createRoomingAssignmentRoute, async (c) => {
661
+ const outcome = await createRoomingAssignment(c.get("db"), c.req.valid("json"));
134
662
  if (outcome.status === "program_not_found")
135
663
  return c.json({ error: "Program not found" }, 404);
136
664
  return c.json({ data: outcome.assignment }, 201);
137
665
  })
138
- .get("/rooming-assignments/:id", async (c) => {
139
- const assignment = await getRoomingAssignment(c.get("db"), c.req.param("id"));
140
- if (!assignment)
141
- return c.json({ error: "Rooming assignment not found" }, 404);
142
- return c.json({ data: assignment });
143
- })
144
- .patch("/rooming-assignments/:id", async (c) => {
145
- const body = await parseJsonBody(c, updateRoomingAssignmentSchema);
146
- const assignment = await updateRoomingAssignment(c.get("db"), c.req.param("id"), body);
147
- if (!assignment)
148
- return c.json({ error: "Rooming assignment not found" }, 404);
149
- return c.json({ data: assignment });
150
- })
151
- .put("/rooming-assignments/:id/delegates", async (c) => {
152
- const { delegates } = await parseJsonBody(c, setRoomingDelegatesSchema);
153
- const outcome = await setRoomingDelegates(c.get("db"), c.req.param("id"), delegates);
666
+ .openapi(getRoomingAssignmentRoute, async (c) => {
667
+ const assignment = await getRoomingAssignment(c.get("db"), c.req.valid("param").id);
668
+ return assignment
669
+ ? c.json({ data: assignment }, 200)
670
+ : c.json({ error: "Rooming assignment not found" }, 404);
671
+ })
672
+ .openapi(updateRoomingAssignmentRoute, async (c) => {
673
+ const assignment = await updateRoomingAssignment(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
674
+ return assignment
675
+ ? c.json({ data: assignment }, 200)
676
+ : c.json({ error: "Rooming assignment not found" }, 404);
677
+ })
678
+ .openapi(setRoomingDelegatesRoute, async (c) => {
679
+ const { delegates } = c.req.valid("json");
680
+ const outcome = await setRoomingDelegates(c.get("db"), c.req.valid("param").id, delegates);
154
681
  switch (outcome.status) {
155
682
  case "ok":
156
- return c.json({ data: outcome.delegates });
683
+ return c.json({ data: outcome.delegates }, 200);
157
684
  case "assignment_not_found":
158
685
  return c.json({ error: "Rooming assignment not found" }, 404);
159
686
  case "delegate_not_found":
@@ -164,52 +691,149 @@ export const miceAdminRoutes = new Hono()
164
691
  detail: { offending: outcome.offending },
165
692
  }, 409);
166
693
  }
167
- })
168
- // Sourcing funnel: RFP → invitations → bids → evaluation → award (Phase 4).
169
- .get("/rfps", async (c) => {
170
- const query = await parseQuery(c, rfpListQuerySchema);
171
- return c.json(await rfpService.listRfps(c.get("db"), query));
172
- })
173
- .post("/rfps", async (c) => {
174
- const body = await parseJsonBody(c, createRfpSchema);
175
- const outcome = await rfpService.createRfp(c.get("db"), body);
694
+ });
695
+ // === RFPs ===================================================================
696
+ const listRfpsRoute = createRoute({
697
+ method: "get",
698
+ path: "/rfps",
699
+ request: { query: rfpListQuerySchema },
700
+ responses: {
701
+ 200: {
702
+ description: "Paginated RFPs",
703
+ content: { "application/json": { schema: listEnvelope(rfpSchema) } },
704
+ },
705
+ },
706
+ });
707
+ const createRfpRoute = createRoute({
708
+ method: "post",
709
+ path: "/rfps",
710
+ request: {
711
+ body: { required: true, content: { "application/json": { schema: createRfpSchema } } },
712
+ },
713
+ responses: {
714
+ 201: {
715
+ description: "The created RFP",
716
+ content: { "application/json": { schema: dataEnvelope(rfpSchema) } },
717
+ },
718
+ 400: invalidRequestResponse,
719
+ 404: notFoundResponse("Program not found"),
720
+ },
721
+ });
722
+ const getRfpRoute = createRoute({
723
+ method: "get",
724
+ path: "/rfps/{id}",
725
+ request: { params: idParamSchema },
726
+ responses: {
727
+ 200: {
728
+ description: "An RFP by id with its invitations and bids",
729
+ content: { "application/json": { schema: dataEnvelope(rfpWithDetailsSchema) } },
730
+ },
731
+ 404: notFoundResponse("RFP not found"),
732
+ },
733
+ });
734
+ const updateRfpRoute = createRoute({
735
+ method: "patch",
736
+ path: "/rfps/{id}",
737
+ request: {
738
+ params: idParamSchema,
739
+ body: { required: true, content: { "application/json": { schema: updateRfpSchema } } },
740
+ },
741
+ responses: {
742
+ 200: {
743
+ description: "The updated RFP",
744
+ content: { "application/json": { schema: dataEnvelope(rfpSchema) } },
745
+ },
746
+ 400: invalidRequestResponse,
747
+ 404: notFoundResponse("RFP not found"),
748
+ },
749
+ });
750
+ const inviteSupplierRoute = createRoute({
751
+ method: "post",
752
+ path: "/rfps/{id}/invitations",
753
+ request: {
754
+ params: idParamSchema,
755
+ body: { required: true, content: { "application/json": { schema: inviteSupplierSchema } } },
756
+ },
757
+ responses: {
758
+ 200: {
759
+ description: "The existing invitation (idempotent)",
760
+ content: { "application/json": { schema: dataEnvelope(rfpInvitationSchema) } },
761
+ },
762
+ 201: {
763
+ description: "The created invitation",
764
+ content: { "application/json": { schema: dataEnvelope(rfpInvitationSchema) } },
765
+ },
766
+ 400: invalidRequestResponse,
767
+ 404: notFoundResponse("RFP not found"),
768
+ },
769
+ });
770
+ const createBidRoute = createRoute({
771
+ method: "post",
772
+ path: "/rfps/{id}/bids",
773
+ request: {
774
+ params: idParamSchema,
775
+ body: { required: true, content: { "application/json": { schema: createBidSchema } } },
776
+ },
777
+ responses: {
778
+ 201: {
779
+ description: "The created bid",
780
+ content: { "application/json": { schema: dataEnvelope(bidSchema) } },
781
+ },
782
+ 400: invalidRequestResponse,
783
+ 404: notFoundResponse("RFP not found"),
784
+ },
785
+ });
786
+ const awardRfpRoute = createRoute({
787
+ method: "post",
788
+ path: "/rfps/{id}/award",
789
+ request: {
790
+ params: idParamSchema,
791
+ body: { required: true, content: { "application/json": { schema: awardRfpSchema } } },
792
+ },
793
+ responses: {
794
+ 200: {
795
+ description: "The awarded RFP and accepted bid",
796
+ content: {
797
+ "application/json": {
798
+ schema: z.object({ data: z.object({ rfp: rfpSchema, bid: bidSchema }) }),
799
+ },
800
+ },
801
+ },
802
+ 400: invalidRequestResponse,
803
+ 404: notFoundResponse("RFP or bid not found"),
804
+ 409: {
805
+ description: "RFP is already awarded",
806
+ content: { "application/json": { schema: errorResponseSchema } },
807
+ },
808
+ },
809
+ });
810
+ const rfpRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
811
+ .openapi(listRfpsRoute, async (c) => c.json(await rfpService.listRfps(c.get("db"), c.req.valid("query")), 200))
812
+ .openapi(createRfpRoute, async (c) => {
813
+ const outcome = await rfpService.createRfp(c.get("db"), c.req.valid("json"));
176
814
  if (outcome.status === "program_not_found")
177
815
  return c.json({ error: "Program not found" }, 404);
178
816
  return c.json({ data: outcome.rfp }, 201);
179
817
  })
180
- .get("/rfps/:id", async (c) => {
181
- const rfp = await rfpService.getRfp(c.get("db"), c.req.param("id"));
182
- if (!rfp)
183
- return c.json({ error: "RFP not found" }, 404);
184
- return c.json({ data: rfp });
185
- })
186
- .patch("/rfps/:id", async (c) => {
187
- const body = await parseJsonBody(c, updateRfpSchema);
188
- const rfp = await rfpService.updateRfp(c.get("db"), c.req.param("id"), body);
189
- if (!rfp)
190
- return c.json({ error: "RFP not found" }, 404);
191
- return c.json({ data: rfp });
192
- })
193
- .post("/rfps/:id/invitations", async (c) => {
194
- const body = await parseJsonBody(c, inviteSupplierSchema);
195
- const outcome = await rfpService.inviteSupplier(c.get("db"), c.req.param("id"), body);
818
+ // static sub-resources before the dynamic `/{id}` catch-all
819
+ .openapi(inviteSupplierRoute, async (c) => {
820
+ const outcome = await rfpService.inviteSupplier(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
196
821
  if (outcome.status === "rfp_not_found")
197
822
  return c.json({ error: "RFP not found" }, 404);
198
823
  return c.json({ data: outcome.invitation }, outcome.idempotent ? 200 : 201);
199
824
  })
200
- .post("/rfps/:id/bids", async (c) => {
201
- const body = await parseJsonBody(c, createBidSchema);
202
- const outcome = await rfpService.createBid(c.get("db"), c.req.param("id"), body);
825
+ .openapi(createBidRoute, async (c) => {
826
+ const outcome = await rfpService.createBid(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
203
827
  if (outcome.status === "rfp_not_found")
204
828
  return c.json({ error: "RFP not found" }, 404);
205
829
  return c.json({ data: outcome.bid }, 201);
206
830
  })
207
- .post("/rfps/:id/award", async (c) => {
208
- const { bidId } = await parseJsonBody(c, awardRfpSchema);
209
- const outcome = await rfpService.awardRfp(c.get("db"), c.req.param("id"), bidId);
831
+ .openapi(awardRfpRoute, async (c) => {
832
+ const { bidId } = c.req.valid("json");
833
+ const outcome = await rfpService.awardRfp(c.get("db"), c.req.valid("param").id, bidId);
210
834
  switch (outcome.status) {
211
835
  case "ok":
212
- return c.json({ data: { rfp: outcome.rfp, bid: outcome.bid } });
836
+ return c.json({ data: { rfp: outcome.rfp, bid: outcome.bid } }, 200);
213
837
  case "rfp_not_found":
214
838
  return c.json({ error: "RFP not found" }, 404);
215
839
  case "bid_not_found":
@@ -218,30 +842,107 @@ export const miceAdminRoutes = new Hono()
218
842
  return c.json({ error: "RFP is already awarded" }, 409);
219
843
  }
220
844
  })
221
- .get("/bids/:id", async (c) => {
222
- const bid = await rfpService.getBid(c.get("db"), c.req.param("id"));
223
- if (!bid)
224
- return c.json({ error: "Bid not found" }, 404);
225
- return c.json({ data: bid });
226
- })
227
- .patch("/bids/:id", async (c) => {
228
- const body = await parseJsonBody(c, updateBidSchema);
229
- const bid = await rfpService.updateBid(c.get("db"), c.req.param("id"), body);
230
- if (!bid)
231
- return c.json({ error: "Bid not found" }, 404);
232
- return c.json({ data: bid });
845
+ .openapi(getRfpRoute, async (c) => {
846
+ const rfp = await rfpService.getRfp(c.get("db"), c.req.valid("param").id);
847
+ return rfp ? c.json({ data: rfp }, 200) : c.json({ error: "RFP not found" }, 404);
233
848
  })
234
- .put("/bids/:id/lines", async (c) => {
235
- const { lines } = await parseJsonBody(c, setBidLinesSchema);
236
- const outcome = await rfpService.setBidLines(c.get("db"), c.req.param("id"), lines);
849
+ .openapi(updateRfpRoute, async (c) => {
850
+ const rfp = await rfpService.updateRfp(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
851
+ return rfp ? c.json({ data: rfp }, 200) : c.json({ error: "RFP not found" }, 404);
852
+ });
853
+ // === bids ===================================================================
854
+ const getBidRoute = createRoute({
855
+ method: "get",
856
+ path: "/bids/{id}",
857
+ request: { params: idParamSchema },
858
+ responses: {
859
+ 200: {
860
+ description: "A bid by id with its lines and evaluations",
861
+ content: { "application/json": { schema: dataEnvelope(bidWithDetailsSchema) } },
862
+ },
863
+ 404: notFoundResponse("Bid not found"),
864
+ },
865
+ });
866
+ const updateBidRoute = createRoute({
867
+ method: "patch",
868
+ path: "/bids/{id}",
869
+ request: {
870
+ params: idParamSchema,
871
+ body: { required: true, content: { "application/json": { schema: updateBidSchema } } },
872
+ },
873
+ responses: {
874
+ 200: {
875
+ description: "The updated bid",
876
+ content: { "application/json": { schema: dataEnvelope(bidSchema) } },
877
+ },
878
+ 400: invalidRequestResponse,
879
+ 404: notFoundResponse("Bid not found"),
880
+ },
881
+ });
882
+ const setBidLinesRoute = createRoute({
883
+ method: "put",
884
+ path: "/bids/{id}/lines",
885
+ request: {
886
+ params: idParamSchema,
887
+ body: { required: true, content: { "application/json": { schema: setBidLinesSchema } } },
888
+ },
889
+ responses: {
890
+ 200: {
891
+ description: "The replaced bid line items",
892
+ content: { "application/json": { schema: dataEnvelope(z.array(bidLineSchema)) } },
893
+ },
894
+ 400: invalidRequestResponse,
895
+ 404: notFoundResponse("Bid not found"),
896
+ },
897
+ });
898
+ const addBidEvaluationRoute = createRoute({
899
+ method: "post",
900
+ path: "/bids/{id}/evaluations",
901
+ request: {
902
+ params: idParamSchema,
903
+ body: { required: true, content: { "application/json": { schema: addBidEvaluationSchema } } },
904
+ },
905
+ responses: {
906
+ 201: {
907
+ description: "The created bid evaluation",
908
+ content: { "application/json": { schema: dataEnvelope(bidEvaluationSchema) } },
909
+ },
910
+ 400: invalidRequestResponse,
911
+ 404: notFoundResponse("Bid not found"),
912
+ },
913
+ });
914
+ const bidRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
915
+ // static sub-resources before the dynamic `/{id}` catch-all
916
+ .openapi(setBidLinesRoute, async (c) => {
917
+ const { lines } = c.req.valid("json");
918
+ const outcome = await rfpService.setBidLines(c.get("db"), c.req.valid("param").id, lines);
237
919
  if (outcome.status === "bid_not_found")
238
920
  return c.json({ error: "Bid not found" }, 404);
239
- return c.json({ data: outcome.lines });
921
+ return c.json({ data: outcome.lines }, 200);
240
922
  })
241
- .post("/bids/:id/evaluations", async (c) => {
242
- const body = await parseJsonBody(c, addBidEvaluationSchema);
243
- const outcome = await rfpService.addBidEvaluation(c.get("db"), c.req.param("id"), body);
923
+ .openapi(addBidEvaluationRoute, async (c) => {
924
+ const outcome = await rfpService.addBidEvaluation(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
244
925
  if (outcome.status === "bid_not_found")
245
926
  return c.json({ error: "Bid not found" }, 404);
246
927
  return c.json({ data: outcome.evaluation }, 201);
928
+ })
929
+ .openapi(getBidRoute, async (c) => {
930
+ const bid = await rfpService.getBid(c.get("db"), c.req.valid("param").id);
931
+ return bid ? c.json({ data: bid }, 200) : c.json({ error: "Bid not found" }, 404);
932
+ })
933
+ .openapi(updateBidRoute, async (c) => {
934
+ const bid = await rfpService.updateBid(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
935
+ return bid ? c.json({ data: bid }, 200) : c.json({ error: "Bid not found" }, 404);
247
936
  });
937
+ /**
938
+ * Compose the per-resource sub-chains onto a single `OpenAPIHono` so the
939
+ * `.openapi()` operations propagate up through any parent registry while keeping
940
+ * type-inference cost bounded (one flat chain has O(n²) inference cost).
941
+ */
942
+ export const miceAdminRoutes = new OpenAPIHono({ defaultHook: openApiValidationHook })
943
+ .route("/", programRoutes)
944
+ .route("/", sessionRoutes)
945
+ .route("/", delegateRoutes)
946
+ .route("/", roomingRoutes)
947
+ .route("/", rfpRoutes)
948
+ .route("/", bidRoutes);