diff --git a/package-lock.json b/package-lock.json index 2435bbea..e3b1aff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "highs": "^1.8.0", "next": "15.0.4", "next-auth": "^5.0.0-beta.25", - "openai": "^4.63.0", + "openai": "^4.104.0", "papaparse": "^5.5.2", "promisify": "^0.0.3", "react-hot-toast": "^2.4.1", diff --git a/package.json b/package.json index f1d71749..44ede823 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "email:dev": "email dev --dir ./src/email --port 4000" }, "dependencies": { - "openai": "^4.63.0", "@azure/storage-blob": "^12.27.0", "@headlessui/react": "^2.2.1", "@phosphor-icons/react": "^2.1.7", @@ -31,6 +30,7 @@ "highs": "^1.8.0", "next": "15.0.4", "next-auth": "^5.0.0-beta.25", + "openai": "^4.104.0", "papaparse": "^5.5.2", "promisify": "^0.0.3", "react-hot-toast": "^2.4.1", diff --git a/prisma/migrations/20251021023559_add_general_item_description/migration.sql b/prisma/migrations/20251021023559_add_general_item_description/migration.sql new file mode 100644 index 00000000..b6fac7ae --- /dev/null +++ b/prisma/migrations/20251021023559_add_general_item_description/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "GeneralItem" ADD COLUMN "description" TEXT; diff --git a/prisma/migrations/20251021164319_remove_type_and_quantity_per_unit_general_item/migration.sql b/prisma/migrations/20251021164319_remove_type_and_quantity_per_unit_general_item/migration.sql new file mode 100644 index 00000000..b586bc37 --- /dev/null +++ b/prisma/migrations/20251021164319_remove_type_and_quantity_per_unit_general_item/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `quantityPerUnit` on the `GeneralItem` table. All the data in the column will be lost. + - You are about to drop the column `type` on the `GeneralItem` table. All the data in the column will be lost. + - A unique constraint covering the columns `[donorOfferId,title,expirationDate,unitType]` on the table `GeneralItem` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "GeneralItem_donorOfferId_title_type_expirationDate_unitType_key"; + +-- AlterTable +ALTER TABLE "GeneralItem" DROP COLUMN "quantityPerUnit", +DROP COLUMN "type"; + +-- CreateIndex +CREATE UNIQUE INDEX "GeneralItem_donorOfferId_title_expirationDate_unitType_key" ON "GeneralItem"("donorOfferId", "title", "expirationDate", "unitType"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ff38b64..0468b983 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -126,18 +126,18 @@ model GeneralItem { donorOffer DonorOffer @relation(fields: [donorOfferId], references: [id], onDelete: Cascade) title String - type String expirationDate DateTime? @db.Date unitType String - quantityPerUnit Int initialQuantity Int requestQuantity Int? + + description String? requests GeneralItemRequest[] items LineItem[] - @@unique([donorOfferId, title, type, expirationDate, unitType, quantityPerUnit]) + @@unique([donorOfferId, title, expirationDate, unitType]) } model GeneralItemRequest { @@ -229,7 +229,7 @@ model PasswordResetToken { } model DescriptionLookup { - // Composite identity constructed as `${title} | ${type} | ${unitType}` + // Composite identity constructed as `${title} | ${unitType}` id String @id description String } \ No newline at end of file diff --git a/scripts/seed.ts b/scripts/seed.ts index af9bb4a7..dbc925c8 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -197,105 +197,94 @@ const partnerDetailsTemplate = { const generalItems: Array> = [ { title: "Advil", - type: "Pain Killer", expirationDate: dateOffset(30), unitType: "Bottle", - quantityPerUnit: 50, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Tylenol", - type: "Pain Killer", expirationDate: dateOffset(4 * 30), unitType: "Bottle", - quantityPerUnit: 20, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Bandages", - type: "First Aid", expirationDate: dateOffset(20 * 12 * 30), unitType: "Box", - quantityPerUnit: 100, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Apples", - type: "Fruit", expirationDate: dateOffset(15), unitType: "Box", - quantityPerUnit: 4, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Bananas", - type: "Fruit", expirationDate: dateOffset(15), unitType: "Bundle", - quantityPerUnit: 4, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Advil", - type: "Pain Killer", expirationDate: dateOffset(30), unitType: "Bottle", - quantityPerUnit: 50, initialQuantity: 100, requestQuantity: 0, + description: null, }, ]; const itemTemplates: Array> = [ { title: "Advil", - type: "Pain Killer", expirationDate: dateOffset(30), unitType: "Bottle", - quantityPerUnit: 50, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Tylenol", - type: "Pain Killer", expirationDate: dateOffset(4 * 30), unitType: "Bottle", - quantityPerUnit: 20, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Bandages", - type: "First Aid", expirationDate: dateOffset(20 * 12 * 30), unitType: "Box", - quantityPerUnit: 100, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Apples", - type: "Fruit", expirationDate: dateOffset(15), unitType: "Box", - quantityPerUnit: 4, initialQuantity: 100, requestQuantity: 0, + description: null, }, { title: "Bananas", - type: "Fruit", expirationDate: dateOffset(15), unitType: "Bundle", - quantityPerUnit: 4, initialQuantity: 100, requestQuantity: 0, + description: null, }, ]; @@ -311,9 +300,8 @@ function genItem( initialQuantity: randInt(1, 4) * 5, requestQuantity: 0, title: template.title, - type: template.type, unitType: template.unitType, - quantityPerUnit: template.quantityPerUnit, + description: null, ...props, }; } @@ -331,6 +319,7 @@ async function run() { await tx.wishlist.deleteMany(); await tx.allocation.deleteMany(); await tx.generalItemRequest.deleteMany(); + await tx.descriptionLookup.deleteMany(); const pwHash = await hash("root"); diff --git a/src/app/api/donorOffers/route.ts b/src/app/api/donorOffers/route.ts index dc9ede3b..43302566 100644 --- a/src/app/api/donorOffers/route.ts +++ b/src/app/api/donorOffers/route.ts @@ -7,10 +7,12 @@ import { ArgumentError, AuthenticationError, AuthorizationError, + InternalError, errorResponse, } from "@/util/errors"; import FileService from "@/services/fileService"; import { tableParamsSchema } from "@/schema/tableParams"; +import { DescriptionService } from "@/services/descriptionService"; export async function POST(req: NextRequest) { try { @@ -24,19 +26,63 @@ export async function POST(req: NextRequest) { "You are not allowed to create donor offers" ); } - + const params = req.nextUrl.searchParams; const formData = await req.formData(); const file = formData.get("file") as File | null; - const preview = formData.get("preview") === "true"; + const preview = params.get("preview") === "true"; if (!file) { throw new ArgumentError("File is required"); } + const toSafeString = (value: unknown) => + typeof value === "string" ? value : value == null ? "" : String(value); + const parsedFileData = await FileService.parseDonorOfferFile(file); + const parsedItems = parsedFileData.data; + if (parsedItems.length === 0) { + throw new ArgumentError("Uploaded file contains no valid rows"); + } + + const needsDescriptions = !preview; + const donorOfferInput = async () => { + if (!needsDescriptions) { + return parsedFileData; + } + + const itemDescriptionInput = parsedItems.map((item) => ({ + title: toSafeString(item["title"]).trim(), + type: toSafeString(item["type"]).trim(), + unitType: toSafeString(item["unitType"]).trim(), + })); + + let descriptions: string[] = []; + try { + descriptions = await DescriptionService.getOrGenerateDescriptions( + itemDescriptionInput + ); + } catch { + throw new InternalError("Failed to generate item descriptions"); + } + + if (descriptions.length !== parsedItems.length) { + throw new InternalError( + "Description generation returned unexpected result" + ); + } + + return { + ...parsedFileData, + data: parsedItems.map((item, index) => ({ + ...item, + description: descriptions[index] ?? null, + })), + }; + }; + const result = await DonorOfferService.createDonorOffer( formData, - parsedFileData, + await donorOfferInput(), preview ); return NextResponse.json(result); diff --git a/src/app/api/suggest/description/route.ts b/src/app/api/suggest/description/route.ts deleted file mode 100644 index 7c0ad3dc..00000000 --- a/src/app/api/suggest/description/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { auth } from "@/auth"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { errorResponse, AuthenticationError, AuthorizationError, ArgumentError } from "@/util/errors"; -import UserService from "@/services/userService"; -import { DescriptionService } from "@/services/descriptionService"; - -const bodySchema = z.object({ - items: z - .array( - z.object({ - title: z.string().min(1), - type: z.string().min(1), - unitType: z.string().min(1), - }) - ) - .min(1), -}); - -export async function POST(req: NextRequest) { - try { - const session = await auth(); - if (!session?.user) { - throw new AuthenticationError("Session required"); - } - - if (!UserService.isStaff(session.user.type)) { - throw new AuthorizationError("Must be STAFF, ADMIN or SUPER_ADMIN"); - } - - const json = await req.json().catch(() => ({})); - const parsed = bodySchema.safeParse(json); - if (!parsed.success) { - throw new ArgumentError(parsed.error.message); - } - - const descriptions = await DescriptionService.getOrGenerateDescriptions(parsed.data.items); - - return NextResponse.json({ items: descriptions }, { status: 200 }); - } catch (error) { - return errorResponse(error); - } -} diff --git a/src/app/donorOffers/create/page.tsx b/src/app/donorOffers/create/page.tsx index 32358b45..93a3c0b0 100644 --- a/src/app/donorOffers/create/page.tsx +++ b/src/app/donorOffers/create/page.tsx @@ -15,12 +15,13 @@ import { } from "@/components/DonorOffers"; import BulkAddLoadingModal from "@/components/BulkAdd/BulkAddLoadingModal"; import { useSession } from "next-auth/react"; -import { redirect } from "next/navigation"; +import { redirect, useRouter } from "next/navigation"; import { useFileUpload } from "@/hooks/useFileUpload"; import { useApiClient } from "@/hooks/useApiClient"; export default function CreateDonorOfferPage() { const { data: session } = useSession(); + const router = useRouter(); const fileInputRef = useRef(null); @@ -47,7 +48,7 @@ export default function CreateDonorOfferPage() { handleFileChange, resetUpload: resetFileUpload, } = useFileUpload<{ donorOfferItems: DonorOfferItem[] }>({ - previewEndpoint: "/api/donorOffers/create?preview=true", + previewEndpoint: "/api/donorOffers?preview=true", onSuccess: (result) => { setData(result.donorOfferItems); }, @@ -93,7 +94,7 @@ export default function CreateDonorOfferPage() { const resetUpload = () => { resetFileUpload(); - + setPreview(false); setIsSuccess(false); setIsError(false); @@ -119,7 +120,7 @@ export default function CreateDonorOfferPage() { }); try { - await apiClient.post("/api/donorOffers/create", { + await apiClient.post("/api/donorOffers", { body: formData, }); setIsSuccess(true); @@ -217,7 +218,7 @@ export default function CreateDonorOfferPage() {
) : (
diff --git a/src/components/BulkAdd/BulkAddLoadingModal.tsx b/src/components/BulkAdd/BulkAddLoadingModal.tsx index b438ca79..b3773adc 100644 --- a/src/components/BulkAdd/BulkAddLoadingModal.tsx +++ b/src/components/BulkAdd/BulkAddLoadingModal.tsx @@ -2,7 +2,9 @@ export default function BulkAddLoadingModal() { return (
-

Uploading...

+

+ Processing donor offer and generating descriptions... +

- Supported Formats: xlsx, csv + This may take a moment while we prepare the items.

diff --git a/src/components/DonorOffers/PreviewTable.tsx b/src/components/DonorOffers/PreviewTable.tsx index edf8396b..9a1b8c4e 100644 --- a/src/components/DonorOffers/PreviewTable.tsx +++ b/src/components/DonorOffers/PreviewTable.tsx @@ -3,11 +3,10 @@ import BaseTable, { tableConditional } from "../baseTable/BaseTable"; // Define donor offer item data type based on schema export type DonorOfferItem = { title: string; - type: string; - expirationDate: Date; + expirationDate?: Date | string | null; unitType: string; - quantityPerUnit: string; - quantity: number; + quantity?: number; + initialQuantity?: number; unitPrice?: number; lotNumber?: string; @@ -18,6 +17,7 @@ export type DonorOfferItem = { hfhShippingNumber?: string; visible?: boolean; notes?: string; + description?: string; }; interface PreviewTableProps { @@ -30,10 +30,8 @@ export const PreviewTable = ({ data, final }: PreviewTableProps) => ( ( "Comment", ]), ]} - rows={data.map((item) => ({ - cells: [ - item.title, - item.type, - item.quantity, - item.unitType, - item.quantityPerUnit, - item.expirationDate - ? new Date(item.expirationDate).toLocaleDateString() - : "N/A", - tableConditional(final, [ - item.unitPrice, - item.lotNumber, - item.palletNumber, - item.boxNumber, - item.maxRequestLimit, - item.donorShippingNumber, - item.hfhShippingNumber, - item.visible ? "Visible" : "Hidden", - item.notes, - ]), - ], - }))} + rows={data.map((item) => { + const displayQuantity = + item.quantity ?? item.initialQuantity ?? 0; + + return { + cells: [ + item.title, + displayQuantity, + item.unitType, + item.expirationDate + ? new Date(item.expirationDate).toLocaleDateString() + : "N/A", + tableConditional(final, [ + item.unitPrice, + item.lotNumber, + item.palletNumber, + item.boxNumber, + item.maxRequestLimit, + item.donorShippingNumber, + item.hfhShippingNumber, + item.visible ? "Visible" : "Hidden", + item.notes, + ]), + ], + }; + })} /> ); diff --git a/src/schema/allocationForm.ts b/src/schema/allocationForm.ts index b5fd763d..83011a3e 100644 --- a/src/schema/allocationForm.ts +++ b/src/schema/allocationForm.ts @@ -6,10 +6,8 @@ export const editAllocationFormSchema = zfd.formData({ // General item search fields title: zfd.text(), - type: zfd.text(), expirationDate: z.coerce.date(), unitType: zfd.text(), - quantityPerUnit: zfd.numeric(z.number().int().min(0)), // Line item search fields donorName: zfd.text(), diff --git a/src/screens/DonorOffersScreens/AdminDynamicDonorOfferScreen.tsx b/src/screens/DonorOffersScreens/AdminDynamicDonorOfferScreen.tsx index d76ab9d9..1ca386c6 100644 --- a/src/screens/DonorOffersScreens/AdminDynamicDonorOfferScreen.tsx +++ b/src/screens/DonorOffersScreens/AdminDynamicDonorOfferScreen.tsx @@ -46,6 +46,7 @@ type GeneralItemWithRequests = { quantityPerUnit: number; initialQuantity: number; requestQuantity: number | null; + description?: string; requests: { id: number; quantity: number; @@ -99,7 +100,6 @@ export default function AdminDynamicDonorOfferScreen() { cell: (i) => i.title, filterType: "string", }, - { id: "type", header: "Type", cell: (i) => i.type, filterType: "string" }, { id: "expirationDate", header: "Expiration", @@ -110,16 +110,16 @@ export default function AdminDynamicDonorOfferScreen() { filterType: "date", }, { id: "unitType", header: "Unit Type", cell: (i) => i.unitType }, - { - id: "quantityPerUnit", - header: "Qty/Unit", - cell: (i) => i.quantityPerUnit, - }, { id: "initialQuantity", header: "Quantity", cell: (i) => i.initialQuantity, }, + { + id: "description", + header: "Description", + cell: (i) => i.description || "N/A", + }, { id: "requestSummary", header: "Request Summary", @@ -132,7 +132,9 @@ export default function AdminDynamicDonorOfferScreen() { ); return (
- {isOpen ? : } + + {isOpen ? : } + @@ -232,7 +234,6 @@ export default function AdminDynamicDonorOfferScreen() { // Handle streaming chunk if ("itemIndex" in chunk && "requests" in chunk) { - const currentItem = currentItems[chunk.itemIndex]; if (currentItem) { updateItemRequests(currentItem.id, chunk.requests); @@ -250,11 +251,11 @@ export default function AdminDynamicDonorOfferScreen() { body: JSON.stringify({ donorOfferId: parseInt(String(donorOfferId)) }), parseChunk: (rawChunk: string) => { buffer += rawChunk; - + const parts = buffer.split("\n\n"); - + buffer = parts.pop() || ""; - + for (const part of parts) { if (part.trim().startsWith("data: ")) { const data = part.substring(part.indexOf("data: ") + 6).trim(); @@ -268,7 +269,7 @@ export default function AdminDynamicDonorOfferScreen() { } } } - + return pendingMessages.shift() || ({ test: "" } as StreamChunk); }, onChunk: (chunk) => { @@ -359,51 +360,56 @@ export default function AdminDynamicDonorOfferScreen() { } }, [apiClient, currentItems]); - const handleRequestUpdated = useCallback((itemId?: number, updatedRequests?: PartnerRequestChipData[]) => { - if (isLLMMode && itemId && updatedRequests) { - // In LLM mode, update the specific item without reloading - tableRef.current?.updateItemById(itemId, (currentItem) => { - return { - ...currentItem, - requests: currentItem.requests.map((req) => { - const updatedReq = updatedRequests.find((r) => r.id === req.id); - if (updatedReq) { + const handleRequestUpdated = useCallback( + (itemId?: number, updatedRequests?: PartnerRequestChipData[]) => { + if (isLLMMode && itemId && updatedRequests) { + // In LLM mode, update the specific item without reloading + tableRef.current?.updateItemById(itemId, (currentItem) => { + return { + ...currentItem, + requests: currentItem.requests.map((req) => { + const updatedReq = updatedRequests.find((r) => r.id === req.id); + if (updatedReq) { + return { + ...req, + finalQuantity: updatedReq.finalQuantity, + }; + } + return req; + }), + }; + }); + + // Also update currentItems state + setCurrentItems((prevItems) => + prevItems.map((item) => { + if (item.id === itemId) { return { - ...req, - finalQuantity: updatedReq.finalQuantity, + ...item, + requests: item.requests.map((req) => { + const updatedReq = updatedRequests.find( + (r) => r.id === req.id + ); + if (updatedReq) { + return { + ...req, + finalQuantity: updatedReq.finalQuantity, + }; + } + return req; + }), }; } - return req; - }), - }; - }); - - // Also update currentItems state - setCurrentItems((prevItems) => - prevItems.map((item) => { - if (item.id === itemId) { - return { - ...item, - requests: item.requests.map((req) => { - const updatedReq = updatedRequests.find((r) => r.id === req.id); - if (updatedReq) { - return { - ...req, - finalQuantity: updatedReq.finalQuantity, - }; - } - return req; - }), - }; - } - return item; - }) - ); - } else { - // Not in LLM mode, reload the table - tableRef.current?.reload(); - } - }, [isLLMMode]); + return item; + }) + ); + } else { + // Not in LLM mode, reload the table + tableRef.current?.reload(); + } + }, + [isLLMMode] + ); const rowBody = useCallback( (item: GeneralItemWithRequests) => { diff --git a/src/services/allocationService.ts b/src/services/allocationService.ts index ad7f2a1d..f45246b9 100644 --- a/src/services/allocationService.ts +++ b/src/services/allocationService.ts @@ -16,10 +16,8 @@ export default class AllocationService { } else { if ( !data.title || - !data.type || !data.expirationDate || !data.unitType || - !data.quantityPerUnit || !data.donorName || !data.lotNumber || !data.palletNumber || @@ -32,10 +30,8 @@ export default class AllocationService { where: { generalItem: { title: data.title, - type: data.type, expirationDate: data.expirationDate, unitType: data.unitType, - quantityPerUnit: data.quantityPerUnit, }, donorName: data.donorName, lotNumber: data.lotNumber, @@ -170,10 +166,8 @@ export default class AllocationService { where: { generalItem: { title: data.title, - type: data.type, expirationDate: data.expirationDate, unitType: data.unitType, - quantityPerUnit: data.quantityPerUnit, }, donorName: data.donorName, lotNumber: data.lotNumber, @@ -202,10 +196,8 @@ export default class AllocationService { const whereClause: Partial = { generalItem: { title: params.title, - type: params.type, expirationDate: params.expirationDate, unitType: params.unitType, - quantityPerUnit: params.quantityPerUnit, }, }; diff --git a/src/services/descriptionService.ts b/src/services/descriptionService.ts index d95a17d7..f59f020c 100644 --- a/src/services/descriptionService.ts +++ b/src/services/descriptionService.ts @@ -3,7 +3,6 @@ import { getOpenAIClient } from "@/lib/azureOpenAI"; export type DescribeItemInput = { title: string; - type: string; unitType: string; }; @@ -14,27 +13,42 @@ type BilingualDescription = { export class DescriptionService { static makeCacheId(item: DescribeItemInput) { - return `${item.title} | ${item.type} | ${item.unitType}`; + return `${item.title} | ${item.unitType}`; } - static async getOrGenerateDescriptions(items: DescribeItemInput[]): Promise { + static async getOrGenerateDescriptions( + items: DescribeItemInput[] + ): Promise { if (items.length === 0) return []; const ids = items.map(DescriptionService.makeCacheId); const existing = await db.descriptionLookup.findMany({ where: { id: { in: ids } }, }); - const existingMap = new Map(existing.map((entry: { id: string; description: string }) => [entry.id, entry.description])); + const existingMap = new Map( + existing.map((entry: { id: string; description: string }) => [ + entry.id, + entry.description, + ]) + ); const missing: { id: string; item: DescribeItemInput }[] = []; + const missingIds = new Set(); ids.forEach((id, index) => { - if (!existingMap.has(id)) missing.push({ id, item: items[index] }); + if (!existingMap.has(id) && !missingIds.has(id)) { + missing.push({ id, item: items[index] }); + missingIds.add(id); + } }); - const generated = await DescriptionService.generateDescriptions(missing.map((entry) => entry.item)); + const generated = await DescriptionService.generateDescriptions( + missing.map((entry) => entry.item) + ); const generatedMap = new Map(); missing.forEach((entry, index) => { - const combined = DescriptionService.combineDescriptions(generated[index] ?? DescriptionService.defaultDescription(entry.item)); + const combined = DescriptionService.combineDescriptions( + generated[index] ?? DescriptionService.defaultDescription(entry.item) + ); generatedMap.set(entry.id, combined); }); @@ -50,25 +64,50 @@ export class DescriptionService { ); } - const finalMap = new Map([...existingMap, ...generatedMap]) as Map; - return ids.map((id, index) => finalMap.get(id) || DescriptionService.combineDescriptions(DescriptionService.defaultDescription(items[index]))); + const finalMap = new Map([...existingMap, ...generatedMap]) as Map< + string, + string + >; + return ids.map( + (id, index) => + finalMap.get(id) || + DescriptionService.combineDescriptions( + DescriptionService.defaultDescription(items[index]) + ) + ); } - private static async generateDescriptions(items: DescribeItemInput[]): Promise { + private static async generateDescriptions( + items: DescribeItemInput[] + ): Promise { if (items.length === 0) return []; const { client } = getOpenAIClient(); if (!client) return items.map(DescriptionService.defaultDescription); - const system = `You write short, neutral product descriptions. + const system = `You write practical medical supply descriptions that explain what each item is used for. +- Focus on what conditions/symptoms the item treats or what medical purposes it serves. - Produce one Haitian Creole sentence and one English sentence for each item. -- Avoid medical claims, dosages, or promises of outcomes. -- Keep language simple and suitable for a broad audience.`; +- Keep language simple and direct - healthcare workers need to know what this helps with, not just what it is. +- For medications: mention the conditions treated, symptoms relieved, or medical uses. +- For supplies: mention the medical procedures or patient care situations where they're used. +- Avoid complex medical jargon, but DO explain the practical medical use.`; const userLines = items - .map((item, index) => `${index + 1}. title="${item.title}", type="${item.type}", unitType="${item.unitType}"`) + .map( + (item, index) => + `${index + 1}. title="${item.title}", unitType="${item.unitType}"` + ) .join("\n"); - const user = `Return a JSON object with an \'items\' array. Each element must include \'haitian\' and \'english\' string fields that describe the matching item. Haitian Creole should be natural and come from the Haitian perspective; English should be clear and simple.\n${userLines}`; + const user = `Return a JSON object with an 'items' array. Each element must include 'haitian' and 'english' string fields. + +IMPORTANT: Describe what each item DOES or what it's USED FOR medically, not just what it is. Healthcare workers need to know what conditions this treats or what medical purposes it serves. + +Examples: +- Instead of "Ibuprofen is a pain medication in tablet form" → "Used to treat pain, fever, and inflammation from conditions like headaches, arthritis, and injuries" +- Instead of "Bandages are wound covering supplies" → "Used to protect wounds, stop bleeding, and prevent infection after injuries or surgery" + +${userLines}`; const response = await client.chat.completions.create({ model: process.env.AZURE_OPENAI_DEPLOYMENT || "omni-moderate", @@ -119,18 +158,24 @@ export class DescriptionService { return items.map(DescriptionService.defaultDescription); } - private static normalizeDescription(value: Partial | undefined, item: DescribeItemInput): BilingualDescription { + private static normalizeDescription( + value: Partial | undefined, + item: DescribeItemInput + ): BilingualDescription { const fallback = DescriptionService.defaultDescription(item); - const haitian = DescriptionService.cleanText(value?.haitian) || fallback.haitian; - const english = DescriptionService.cleanText(value?.english) || fallback.english; + const haitian = + DescriptionService.cleanText(value?.haitian) || fallback.haitian; + const english = + DescriptionService.cleanText(value?.english) || fallback.english; return { haitian, english }; } - private static defaultDescription(item: DescribeItemInput): BilingualDescription { - const lowerType = item.type.toLowerCase(); + private static defaultDescription( + item: DescribeItemInput + ): BilingualDescription { const lowerUnit = item.unitType.toLowerCase(); - const english = `"${item.title}" is a ${lowerType} offered in ${lowerUnit} units.`; - const haitian = `"${item.title}" se yon ${lowerType} ki disponib nan inite ${lowerUnit}.`; + const english = `Medical supply: ${item.title}. Consult medical staff for proper use and indications. Supplied in ${lowerUnit} units.`; + const haitian = `Founiti medikal: ${item.title}. Konsилte pèsonèl medikal pou jan pou itilize l kòrèkteman. Disponib nan inite ${lowerUnit}.`; return { haitian, english }; } @@ -141,6 +186,9 @@ export class DescriptionService { } private static cleanText(value: string | undefined): string { - return (value ?? "").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); + return (value ?? "") + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); } } diff --git a/src/services/donorOfferService.ts b/src/services/donorOfferService.ts index db572e02..306f142e 100644 --- a/src/services/donorOfferService.ts +++ b/src/services/donorOfferService.ts @@ -31,8 +31,7 @@ import { Filters } from "@/types/api/filter.types"; import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; const DonorOfferItemSchema = z.object({ - title: z.string().min(1, "Title is required"), - type: z.string(), + title: z.string().trim().min(1, "Title is required"), initialQuantity: z .string() .transform((val) => (val.trim() === "" ? undefined : Number(val))) @@ -43,24 +42,23 @@ const DonorOfferItemSchema = z.object({ z.string().transform((val) => (val.trim() === "" ? undefined : val)), ]) .optional(), - unitType: z.string(), - quantityPerUnit: z + unitType: z.string().trim(), + description: z .string() - .transform((val) => (val.trim() === "" ? undefined : Number(val))) - .pipe(z.number().int().min(0, "Quantity must be non-negative")), + .transform((val) => val.trim()) + .optional(), }); const DonorOfferSchema = z.object({ - offerName: z.string().min(1, "Offer name is required"), - donorName: z.string().min(1, "Donor name is required"), + offerName: z.string().trim().min(1, "Offer name is required"), + donorName: z.string().trim().min(1, "Donor name is required"), partnerResponseDeadline: z.coerce.date(), donorResponseDeadline: z.coerce.date(), state: z.nativeEnum(DonorOfferState).default(DonorOfferState.UNFINALIZED), }); const FinalizeDonorOfferItemSchema = z.object({ - title: z.string().min(1, "Title is required"), - type: z.string(), + title: z.string().trim().min(1, "Title is required"), expirationDate: z .union([ z.coerce.date(), @@ -74,10 +72,6 @@ const FinalizeDonorOfferItemSchema = z.object({ }) .optional(), unitType: z.string(), - quantityPerUnit: z - .string() - .transform((val) => (val.trim() === "" ? undefined : Number(val))) - .pipe(z.number().int().min(0, "Quantity must be non-negative")), category: z.nativeEnum(ItemCategory), quantity: z .string() @@ -130,6 +124,95 @@ const FinalizeDonorOfferSchema = z.object({ type FinalizeDonorOfferItem = z.infer; export default class DonorOfferService { + + private static normalizeExpirationDate(value: unknown): Date | null { + if (!value) { + return null; + } + + if (value instanceof Date) { + const normalized = new Date(value); + normalized.setUTCHours(0, 0, 0, 0); + return isNaN(normalized.getTime()) ? null : normalized; + } + + if (typeof value === "string") { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + parsed.setUTCHours(0, 0, 0, 0); + return parsed; + } + } + + return null; + } + + private static toGeneralItemCreateInputs( + items: (typeof DonorOfferItemSchema._type)[], + donorOfferId: number + ): Prisma.GeneralItemCreateManyInput[] { + return items.map((item) => ({ + donorOfferId, + title: item.title, + expirationDate: + DonorOfferService.normalizeExpirationDate(item.expirationDate) ?? null, + unitType: item.unitType, + initialQuantity: item.initialQuantity, + description: + item.description && item.description.length > 0 + ? item.description + : null, + })); + } + + private static aggregateGeneralItems( + items: Prisma.GeneralItemCreateManyInput[] + ): Prisma.GeneralItemCreateManyInput[] { + const map = new Map(); + + for (const item of items) { + const keyParts = [ + item.donorOfferId, + item.title, + item.expirationDate instanceof Date + ? item.expirationDate.toISOString().split("T")[0] + : item.expirationDate ?? "null", + item.unitType, + ]; + const key = keyParts.join("|"); + + const existing = map.get(key); + if (existing) { + existing.initialQuantity += item.initialQuantity; + + if (!existing.description && item.description) { + existing.description = item.description; + } + continue; + } + + map.set(key, { ...item }); + } + + return Array.from(map.values()); + } + + private static aggregatePreviewItems( + items: (typeof DonorOfferItemSchema._type)[] + ): (typeof DonorOfferItemSchema._type)[] { + const aggregated = DonorOfferService.aggregateGeneralItems( + DonorOfferService.toGeneralItemCreateInputs(items, 0) + ); + + return aggregated.map((item) => ({ + title: item.title, + expirationDate: item.expirationDate ?? undefined, + unitType: item.unitType, + initialQuantity: item.initialQuantity, + description: item.description ?? undefined, + })); + } + static async getPartnerDonorOffers( partnerId: number, filters?: Filters, @@ -403,7 +486,6 @@ export default class DonorOfferService { const { data: jsonData } = parsedFileData; const validDonorOfferItems: (typeof DonorOfferItemSchema._type)[] = []; const errors: string[] = []; - jsonData.forEach((row, index) => { const parsed = DonorOfferItemSchema.safeParse(row); if (!parsed.success) { @@ -446,9 +528,11 @@ export default class DonorOfferService { } if (preview) { + const previewItems = + DonorOfferService.aggregatePreviewItems(validDonorOfferItems); return { success: true, - donorOfferItems: validDonorOfferItems.slice(0, 8), + donorOfferItems: previewItems.slice(0, 8), }; } @@ -457,14 +541,18 @@ export default class DonorOfferService { data: donorOfferData, }); - const itemsWithDonorOfferId = validDonorOfferItems.map((item) => ({ - ...item, - donorOfferId: donorOffer.id, - })); + const normalizedItems = + DonorOfferService.toGeneralItemCreateInputs( + validDonorOfferItems, + donorOffer.id + ); - await tx.generalItem.createMany({ - data: itemsWithDonorOfferId, - }); + const aggregatedItems = + DonorOfferService.aggregateGeneralItems(normalizedItems); + + if (aggregatedItems.length > 0) { + await tx.generalItem.createMany({ data: aggregatedItems }); + } }); return { success: true }; @@ -494,13 +582,11 @@ export default class DonorOfferService { ({ donorOfferItemId: item.id, title: item.title, - type: item.type, expiration: item.expirationDate === null ? null : format(item.expirationDate, "MM/dd/yyyy"), initialQuantity: item.initialQuantity, - unitSize: item.quantityPerUnit, ...(item.requests[0] ? { requestId: item.requests[0].id, @@ -605,7 +691,6 @@ export default class DonorOfferService { where: { donorOfferId: donorOfferId, title: itemRequest.title, - type: itemRequest.type, expirationDate: itemRequest.expirationDate, unitType: itemRequest.unitType, }, @@ -794,15 +879,26 @@ export default class DonorOfferService { const itemsWithDonorOfferItemId = validItems.map((item) => ({ ...item, donorName: donorOffer.donorName, - donorOfferItemId: donorOffer.items.find( - (di) => - item.title == di.title && - item.type === di.type && - (item.expirationDate as Date).getTime() === - (di.expirationDate as Date).getTime() && - item.unitType === di.unitType && - item.quantityPerUnit == di.quantityPerUnit - )?.id, + donorOfferItemId: donorOffer.items.find((di) => { + const itemExpiration = + DonorOfferService.normalizeExpirationDate( + item.expirationDate + ); + const diExpiration = di.expirationDate + ? DonorOfferService.normalizeExpirationDate(di.expirationDate) + : null; + const expirationMatches = + itemExpiration === null && diExpiration === null + ? true + : !!itemExpiration && + !!diExpiration && + itemExpiration.getTime() === diExpiration.getTime(); + return ( + item.title === di.title && + expirationMatches && + item.unitType === di.unitType + ); + })?.id, })); await tx.lineItem.createMany({ data: itemsWithDonorOfferItemId }); diff --git a/src/services/fileService.ts b/src/services/fileService.ts index 601e5839..9b59fa2d 100644 --- a/src/services/fileService.ts +++ b/src/services/fileService.ts @@ -12,20 +12,61 @@ import { UploadUrlResult, RecentFileResult, ReadUrlResult, - ParsedFileData + ParsedFileData, } from "@/types/api/file.types"; -const requiredKeys = [ - "title", - "type", - "quantity", - "expirationDate", - "unitType", - "quantityPerUnit", -]; +const requiredKeys = new Map([ + ["Generic Description", "title"], + ["Quantity", "quantity"], + ["Expiration Date", "expirationDate"], + ["Unit UOM", "unitType"], +]); + +const requiredKeyList = Array.from(requiredKeys.keys()); const containsRequiredKeys = (fields?: string[]) => - fields ? requiredKeys.every((key) => fields.includes(key)) : false; + fields ? requiredKeyList.every((key) => fields.includes(key)) : false; + +const hasValue = (value: unknown) => { + if (value === undefined || value === null) return false; + if (typeof value === "string") { + return value.trim() !== ""; + } + return true; +}; + +const filterEmptyRows = (rows: Record[]) => + rows.filter((row) => hasValue(row["Generic Description"])); + +const remapRequiredColumns = ( + row: Record +): Record => { + const updated: Record = { ...row }; + for (const [originalKey, newKey] of requiredKeys) { + if (Object.prototype.hasOwnProperty.call(updated, originalKey)) { + updated[newKey] = updated[originalKey]; + delete updated[originalKey]; + } + } + return updated; +}; + +const transformDonorOfferRow = ( + row: Record +): Record => { + const remapped = remapRequiredColumns(row); + const quantity = remapped["quantity"]; + const transformed: Record = { + ...remapped, + }; + + if (quantity !== undefined) { + transformed["initialQuantity"] = quantity; + } + + delete transformed["quantity"]; + return transformed; +}; const containerName = process.env.AZURE_STORAGE_CONTAINER_NAME; const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; @@ -145,7 +186,9 @@ export default class FileService { static async parseDonorOfferFile(file: File): Promise { const fileExt = file.name.split(".").pop()?.toLowerCase(); if (!["csv", "xlsx"].includes(fileExt || "")) { - throw new ArgumentError(`Error opening ${file.name}: must be a valid CSV or XLSX file`); + throw new ArgumentError( + `Error opening ${file.name}: must be a valid CSV or XLSX file` + ); } try { @@ -160,10 +203,6 @@ export default class FileService { const csvText = XLSX.utils.sheet_to_csv(sheet); const { data, meta } = Papa.parse(csvText, { header: true }); - if (!meta.fields || !containsRequiredKeys(meta.fields)) { - throw new ArgumentError("CSV does not contain required keys"); - } - jsonData = data as Record[]; fields = meta.fields; } else if (fileExt === "csv") { @@ -173,15 +212,18 @@ export default class FileService { skipEmptyLines: true, }); - if (!meta.fields || !containsRequiredKeys(meta.fields)) { - throw new ArgumentError("CSV does not contain required keys"); - } - jsonData = data as Record[]; fields = meta.fields; } + jsonData = filterEmptyRows(jsonData); - return { data: jsonData, fields }; + if (!fields || !containsRequiredKeys(fields)) { + throw new ArgumentError("File does not contain required keys"); + } + + const transformedData = jsonData.map(transformDonorOfferRow); + + return { data: transformedData, fields }; } catch (error) { if (error instanceof ArgumentError) { throw error; @@ -208,24 +250,25 @@ export default class FileService { const csvText = XLSX.utils.sheet_to_csv(sheet); const { data, meta } = Papa.parse(csvText, { header: true }); - if (!meta.fields || !containsRequiredKeys(meta.fields)) { - throw new ArgumentError("CSV does not contain required keys"); - } - jsonData = data as Record[]; fields = meta.fields; } else { const text = await file.text(); - const { data, meta } = Papa.parse(text, { header: true, skipEmptyLines: true }); - - if (!meta.fields || !containsRequiredKeys(meta.fields)) { - throw new ArgumentError("CSV does not contain required keys"); - } + const { data, meta } = Papa.parse(text, { + header: true, + skipEmptyLines: true, + }); jsonData = data as Record[]; fields = meta.fields; } + jsonData = filterEmptyRows(jsonData).map(remapRequiredColumns); + + if (!fields || !containsRequiredKeys(fields)) { + throw new ArgumentError("File does not contain required keys"); + } + return { data: jsonData, fields }; } catch (error) { if (error instanceof ArgumentError) { diff --git a/src/types/api/allocation.types.ts b/src/types/api/allocation.types.ts index 5fa3d29e..6a72af07 100644 --- a/src/types/api/allocation.types.ts +++ b/src/types/api/allocation.types.ts @@ -10,10 +10,8 @@ export interface CreateAllocationData extends Partial { export interface UpdateAllocationData { allocationId: number; title: string; - type: string; expirationDate: Date; unitType: string; - quantityPerUnit: number; donorName: string; lotNumber: string; palletNumber: string; @@ -22,10 +20,8 @@ export interface UpdateAllocationData { export interface ItemSearchParams { title: string; - type: string; expirationDate: Date; unitType: string; - quantityPerUnit: number; donorName?: string; lotNumber?: string; palletNumber?: string; diff --git a/src/types/api/distribution.types.ts b/src/types/api/distribution.types.ts index 5c3d7394..8ebb0718 100644 --- a/src/types/api/distribution.types.ts +++ b/src/types/api/distribution.types.ts @@ -2,10 +2,8 @@ import { LineItem, ShipmentStatus } from "@prisma/client"; export interface AllocatedItem { title: string; - type: string; expirationDate: Date | null; unitType: string; - quantityPerUnit: number; quantityAllocated: number; shipmentStatus: ShipmentStatus; } diff --git a/src/types/api/donorOffer.types.ts b/src/types/api/donorOffer.types.ts index cccd1f80..87fe7b3e 100644 --- a/src/types/api/donorOffer.types.ts +++ b/src/types/api/donorOffer.types.ts @@ -39,7 +39,7 @@ export interface AdminDonorOffersResponse { export type GeneralItemRequestWithDetails = GeneralItemRequest & { generalItem: Pick< GeneralItem, - "id" | "title" | "type" | "expirationDate" | "unitType" | "quantityPerUnit" + "id" | "title" | "expirationDate" | "unitType" >; partner: { name: string; @@ -60,10 +60,8 @@ export interface CreateDonorOfferResult { errors?: string[]; donorOfferItems?: { title: string; - type: string; initialQuantity: number; unitType: string; - quantityPerUnit: number; expirationDate?: Date | string; }[]; } @@ -71,10 +69,8 @@ export interface CreateDonorOfferResult { export interface DonorOfferItemsRequestsDTO { donorOfferItemId: number; title: string; - type: string; expiration: string | null; initialQuantity: number; - unitSize: number; requestId: number | null; quantityRequested: number; comments: string | null; @@ -89,7 +85,6 @@ export interface DonorOfferItemsRequestsResponse { export interface UpdateRequestItem { title: string; - type: string; expirationDate: string; unitType: string; quantity?: number; diff --git a/src/types/api/generalItem.types.ts b/src/types/api/generalItem.types.ts index 02e07ae0..113a7943 100644 --- a/src/types/api/generalItem.types.ts +++ b/src/types/api/generalItem.types.ts @@ -1,10 +1,8 @@ export interface CreateGeneralItemParams { donorOfferId: number; title: string; - type: string; expirationDate?: Date; unitType: string; - quantityPerUnit: number; initialQuantity: number; requestQuantity?: number; } diff --git a/src/types/api/item.types.ts b/src/types/api/item.types.ts index 69fbeed8..64da3d3e 100644 --- a/src/types/api/item.types.ts +++ b/src/types/api/item.types.ts @@ -5,10 +5,8 @@ export type CreateItemData = ItemForm; export interface BulkItemData { title: string; - type: string; expirationDate?: Date | string; unitType: string; - quantityPerUnit: number; donorName: string; category: ItemCategory; quantity: number;