diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index 2990179..d4db5be 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -2,13 +2,14 @@ import { z } from 'zod'; import { getDbClient } from '../dbClient'; -import { Bill, BilledTo, BillAttachment, BillingLocation, YearMonth } from '../db-types'; +import { Bill, BilledTo, FileAttachment, BillingLocation, YearMonth } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; import { gotoHome, gotoHomeWithMessage } from './navigationActions'; import { getTranslations, getLocale } from "next-intl/server"; import { IntlTemplateFn } from '@/app/i18n'; +import { unstable_noStore } from 'next/cache'; export type State = { errors?: { @@ -17,21 +18,21 @@ export type State = { billNotes?: string[], payedAmount?: string[], }; - message?:string | null; + message?: string | null; } /** * Schema for validating bill form fields * @description this is defined as factory function so that it can be used with the next-intl library */ -const FormSchema = (t:IntlTemplateFn) => z.object({ +const FormSchema = (t: IntlTemplateFn) => z.object({ _id: z.string(), billName: z.coerce.string().min(1, t("bill-name-required")), billNotes: z.string(), addToSubsequentMonths: z.boolean().optional(), payedAmount: z.string().nullable().transform((val, ctx) => { - if(!val || val === '') { + if (!val || val === '') { return null; } @@ -42,7 +43,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ code: z.ZodIssueCode.custom, message: t("not-a-number"), }); - + // This is a special symbol you can use to // return early from the transform function. // It has type `never` so it does not affect the @@ -55,25 +56,25 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ code: z.ZodIssueCode.custom, message: t("negative-number") }); - + // This is a special symbol you can use to // return early from the transform function. // It has type `never` so it does not affect the // inferred return type. return z.NEVER; } - + return Math.floor(parsed * 100); // value is stored in cents - }), - }); + }), +}); /** * converts the file to a format stored in the database * @param billAttachment * @returns */ -const serializeAttachment = async (billAttachment: File | null) => { +const serializeAttachment = async (billAttachment: File | null): Promise => { if (!billAttachment) { return null; @@ -86,7 +87,7 @@ const serializeAttachment = async (billAttachment: File | null) => { lastModified: fileLastModified, } = billAttachment; - if(!fileName || fileName === 'undefined' || fileSize === 0) { + if (!fileName || fileName === 'undefined' || fileSize === 0) { return null; } @@ -95,13 +96,14 @@ const serializeAttachment = async (billAttachment: File | null) => { const fileContentsBase64 = Buffer.from(fileContents).toString('base64'); // create an object to store the file in the database - return({ + return ({ fileName, fileSize, fileType, fileLastModified, fileContentsBase64, - } as BillAttachment); + uploadedAt: new Date() + }); } /** @@ -112,7 +114,7 @@ const serializeAttachment = async (billAttachment: File | null) => { * @param formData form data * @returns */ -export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationId: string, billId:string|undefined, billYear:number|undefined, billMonth:number|undefined, prevState:State, formData: FormData) => { +export const updateOrAddBill = withUser(async (user: AuthenticatedUser, locationId: string, billId: string | undefined, billYear: number | undefined, billMonth: number | undefined, prevState: State, formData: FormData) => { const { id: userId } = user; @@ -129,9 +131,9 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI }); // If form validation fails, return errors early. Otherwise, continue... - if(!validatedFields.success) { + if (!validatedFields.success) { console.log("updateBill.validation-error"); - return({ + return ({ errors: validatedFields.error.flatten().fieldErrors, message: t("form-error-message"), }); @@ -150,10 +152,26 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI // update the bill in the mongodb const dbClient = await getDbClient(); - - const billAttachment = await serializeAttachment(formData.get('billAttachment') as File); - if(billId) { + // First validate that the file is acceptable + const attachmentFile = formData.get('billAttachment') as File; + + // validate max file size from env variable + const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10); + const maxFileSizeBytes = maxFileSizeKB * 1024; + + if (attachmentFile && attachmentFile.size > maxFileSizeBytes) { + return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` }; + } + + // Validate file type + if (attachmentFile && attachmentFile.type !== 'application/pdf') { + return { success: false, error: 'Only PDF files are accepted' }; + } + + const billAttachment = await serializeAttachment(attachmentFile); + + if (billId) { // if there is an attachment, update the attachment field // otherwise, do not update the attachment field @@ -165,8 +183,8 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI "bills.$[elem].notes": billNotes, "bills.$[elem].payedAmount": payedAmount, "bills.$[elem].hub3aText": hub3aText, - - }: { + + } : { "bills.$[elem].name": billName, "bills.$[elem].paid": billPaid, "bills.$[elem].billedTo": billedTo, @@ -175,8 +193,8 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI "bills.$[elem].hub3aText": hub3aText, }; - // find a location with the given locationID - const post = await dbClient.collection("lokacije").updateOne( + // update bill in given location with the given locationID + await dbClient.collection("lokacije").updateOne( { _id: locationId, // find a location with the given locationID userId // make sure that the location belongs to the user @@ -184,10 +202,10 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI { $set: mongoDbSet }, { - arrayFilters: [ - { "elem._id": { $eq: billId } } // find a bill with the given billID - ] - }); + arrayFilters: [ + { "elem._id": { $eq: billId } } // find a bill with the given billID + ] + }); } else { // Create new bill - add to current location first const newBill = { @@ -227,13 +245,13 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI name: currentLocation.name, $or: [ { "yearMonth.year": { $gt: billYear } }, - { - "yearMonth.year": billYear, - "yearMonth.month": { $gt: billMonth } + { + "yearMonth.year": billYear, + "yearMonth.month": { $gt: billMonth } } ] }, { projection: { _id: 1 } }) - .toArray(); + .toArray(); // For each subsequent location, check if bill with same name already exists const updateOperations = []; @@ -278,7 +296,7 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI } } } - if(billYear && billMonth) { + if (billYear && billMonth) { const locale = await getLocale(); await gotoHomeWithMessage(locale, 'billSaved', { year: billYear, month: billMonth }); } @@ -331,7 +349,7 @@ export const fetchBillByUserAndId = withUser(async (user:AuthenticatedUser, loca }) */ -export const fetchBillById = async (locationID:string, billID:string, includeAttachmentBinary:boolean = false) => { +export const fetchBillById = async (locationID: string, billID: string, includeAttachmentBinary: boolean = false) => { const dbClient = await getDbClient(); @@ -351,44 +369,44 @@ export const fetchBillById = async (locationID:string, billID:string, includeAtt projection }) - if(!billLocation) { + if (!billLocation) { console.log(`Location ${locationID} not found`); - return(null); + return (null); } // find a bill with the given billID const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); - if(!bill) { + if (!bill) { console.log('Bill not found'); - return(null); + return (null); } - return([billLocation, bill] as [BillingLocation, Bill]); + return ([billLocation, bill] as [BillingLocation, Bill]); }; -export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number, month:number, _prevState:any, formData?: FormData) => { +export const deleteBillById = withUser(async (user: AuthenticatedUser, locationID: string, billID: string, year: number, month: number, _prevState: any, formData?: FormData) => { const { id: userId } = user; const dbClient = await getDbClient(); - + const deleteInSubsequentMonths = formData?.get('deleteInSubsequentMonths') === 'on'; if (deleteInSubsequentMonths) { // Get the current location and bill to find the bill name and location name const location = await dbClient.collection("lokacije") - .findOne({ _id: locationID, userId }, { + .findOne({ _id: locationID, userId }, { projection: { "name": 1, "bills._id": 1, "bills.name": 1 - } + } }); if (location) { const bill = location.bills.find(b => b._id === billID); - + if (bill) { // Find all subsequent locations with the same name that have the same bill const subsequentLocations = await dbClient.collection("lokacije") @@ -397,9 +415,9 @@ export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID name: location.name, $or: [ { "yearMonth.year": { $gt: year } }, - { - "yearMonth.year": year, - "yearMonth.month": { $gt: month } + { + "yearMonth.year": year, + "yearMonth.month": { $gt: month } } ], "bills.name": bill.name @@ -461,4 +479,95 @@ export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID message: null, errors: undefined, }; -}); \ No newline at end of file +}); + +/** + * Uploads proof of payment for the given bill + * @param locationID - The ID of the location + * @param formData - FormData containing the file + * @returns Promise with success status + */ +export const uploadProofOfPayment = async (locationID: string, billID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => { + unstable_noStore(); + + try { + // First validate that the file is acceptable + const file = formData.get('utilBillsProofOfPayment') as File; + + // validate max file size from env variable + const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10); + const maxFileSizeBytes = maxFileSizeKB * 1024; + + if (file && file.size > maxFileSizeBytes) { + return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` }; + } + + // Validate file type + if (file && file.type !== 'application/pdf') { + return { success: false, error: 'Only PDF files are accepted' }; + } + + // update the bill in the mongodb + const dbClient = await getDbClient(); + + const projection = { + "bills.attachment": 0, + // don't include the attachment - save the bandwidth it's not needed here + "bills.proofOfPayment.uploadedAt": 1, + // ommit only the file contents - we need to know if a file was already uploaded + "bills.proofOfPayment.fileContentsBase64": 0, + }; + + // Checking if proof of payment already exists + + // find a location with the given locationID + const billLocation = await dbClient.collection("lokacije").findOne( + { + _id: locationID, + }, + { + projection + }) + + if (!billLocation) { + console.log(`Location ${locationID} not found - Proof of payment upload failed`); + return { success: false, error: "Location not found - Proof of payment upload failed" }; + } + + // find a bill with the given billID + const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); + + + if (bill?.proofOfPayment?.uploadedAt) { + return { success: false, error: 'Proof payment already uploaded for this bill' }; + } + + const attachment = await serializeAttachment(file); + + if (!attachment) { + return { success: false, error: 'Invalid file' }; + } + + // Add proof of payment to the bill + await dbClient.collection("lokacije").updateOne( + { + _id: locationID // find a location with the given locationID + }, + { + $set: { + "bills.$[elem].proofOfPayment": { + ...attachment + } + } + }, { + arrayFilters: [ + { "elem._id": { $eq: billID } } // find a bill with the given billID + ] + }); + + return { success: true }; + } catch (error: any) { + console.error('Error uploading proof of payment for a bill:', error); + return { success: false, error: error.message || 'Upload failed' }; + } +} \ No newline at end of file