'use server'; import { z } from 'zod'; import { getDbClient } from '../dbClient'; import { Bill, BilledTo, FileAttachment, BillingLocation } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; import { gotoHomeWithMessage } from './navigationActions'; import { getTranslations, getLocale } from "next-intl/server"; import { IntlTemplateFn } from '@/app/i18n'; import { unstable_noStore, revalidatePath } from 'next/cache'; import { extractShareId, validateShareChecksum } from '../shareChecksum'; import { validatePdfFile } from '../validators/pdfValidator'; import { checkUploadRateLimit } from '../uploadRateLimiter'; export type State = { errors?: { billName?: string[]; billAttachment?: string[], billNotes?: string[], payedAmount?: string[], }; 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({ _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 === '') { return null; } const parsed = parseFloat(val.replace(',', '.')); if (isNaN(parsed)) { ctx.addIssue({ 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 // inferred return type. return z.NEVER; } if (parsed < 0) { ctx.addIssue({ 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): Promise => { if (!billAttachment) { return null; } const { name: fileName, size: fileSize, type: fileType, lastModified: fileLastModified, } = billAttachment; if (!fileName || fileName === 'undefined' || fileSize === 0) { return null; } // convert the billAttachment file contents to format that can be stored in the database const fileContents = await billAttachment.arrayBuffer(); const fileContentsBase64 = Buffer.from(fileContents).toString('base64'); // create an object to store the file in the database return ({ fileName, fileSize, fileType, fileLastModified, fileContentsBase64, uploadedAt: new Date() }); } /** * Server-side action which adds or updates a bill * @param locationId location of the bill * @param billId ID of the bill * @param prevState previous state of the form * @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) => { unstable_noStore(); const { id: userId } = user; const t = await getTranslations("bill-edit-form.validation"); // FormSchema const validatedFields = FormSchema(t) .omit({ _id: true }) .safeParse({ billName: formData.get('billName'), billNotes: formData.get('billNotes'), addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on', payedAmount: formData.get('payedAmount'), }); // If form validation fails, return errors early. Otherwise, continue... if (!validatedFields.success) { console.log("updateBill.validation-error"); return ({ errors: validatedFields.error.flatten().fieldErrors, message: t("form-error-message"), }); } const { billName, billNotes, addToSubsequentMonths, payedAmount, } = validatedFields.data; const billPaid = formData.get('billPaid') === 'on'; const billedTo = (formData.get('billedTo') as BilledTo) ?? BilledTo.Tenant; const hub3aText = formData.get('hub3aText')?.valueOf() as string; // update the bill in the mongodb const dbClient = await getDbClient(); // 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.size > 0 && 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 const mongoDbSet = billAttachment ? { "bills.$[elem].name": billName, "bills.$[elem].paid": billPaid, "bills.$[elem].billedTo": billedTo, "bills.$[elem].attachment": billAttachment, "bills.$[elem].notes": billNotes, "bills.$[elem].payedAmount": payedAmount, "bills.$[elem].hub3aText": hub3aText, } : { "bills.$[elem].name": billName, "bills.$[elem].paid": billPaid, "bills.$[elem].billedTo": billedTo, "bills.$[elem].notes": billNotes, "bills.$[elem].payedAmount": payedAmount, "bills.$[elem].hub3aText": hub3aText, }; // 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 }, { $set: mongoDbSet }, { arrayFilters: [ { "elem._id": { $eq: billId } } // find a bill with the given billID ] }); } else { // Create new bill - add to current location first const newBill = { _id: (new ObjectId()).toHexString(), name: billName, paid: billPaid, billedTo: billedTo, attachment: billAttachment, notes: billNotes, payedAmount, hub3aText, }; // Add to current location await dbClient.collection("lokacije").updateOne( { _id: locationId, // find a location with the given locationID userId // make sure that the location belongs to the user }, { $push: { bills: newBill } }); // If addToSubsequentMonths is enabled, add to subsequent months if (addToSubsequentMonths && billYear && billMonth) { // Get the current location to find its name const currentLocation = await dbClient.collection("lokacije") .findOne({ _id: locationId, userId }, { projection: { name: 1 } }); if (currentLocation) { // Find all subsequent months that have the same location name const subsequentLocations = await dbClient.collection("lokacije") .find({ userId, name: currentLocation.name, $or: [ { "yearMonth.year": { $gt: billYear } }, { "yearMonth.year": billYear, "yearMonth.month": { $gt: billMonth } } ] }, { projection: { _id: 1 } }) .toArray(); // For each subsequent location, check if bill with same name already exists const updateOperations = []; for (const location of subsequentLocations) { const existingBill = await dbClient.collection("lokacije") .findOne({ _id: location._id, "bills.name": billName }, { // We only need to know if a matching bill exists; avoid conflicting projections projection: { _id: 1 } }); // Only add if bill with same name doesn't already exist if (!existingBill) { updateOperations.push({ updateOne: { filter: { _id: location._id, userId }, update: { $push: { bills: { _id: (new ObjectId()).toHexString(), name: billName, paid: false, // New bills in subsequent months are unpaid billedTo: BilledTo.Tenant, // Default to tenant for subsequent months attachment: null, // No attachment for subsequent months notes: billNotes, payedAmount: null, hub3aText: undefined, } } } } }); } } // Execute all update operations at once if any if (updateOperations.length > 0) { await dbClient.collection("lokacije").bulkWrite(updateOperations); } } } } if (billYear && billMonth) { const locale = await getLocale(); await gotoHomeWithMessage(locale, 'billSaved', { year: billYear, month: billMonth }); } // This return is needed for TypeScript, but won't be reached due to redirect return { message: null, errors: undefined, }; }) /* Funkcija zamijenjena sa `fetchBillByUserAndId`, koja brže radi i ne treba korisnika export const fetchBillByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, includeAttachmentBinary:boolean = false) => { const { id: userId } = user; const dbClient = await getDbClient(); // don't include the attachment binary data in the response // if the attachment binary data is not needed const projection = includeAttachmentBinary ? {} : { "bills.attachment.fileContentsBase64": 0, }; // find a location with the given locationID const billLocation = await dbClient.collection("lokacije").findOne( { _id: locationID, userId }, { projection }) if(!billLocation) { console.log(`Location ${locationID} not found`); return(null); } // find a bill with the given billID const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); if(!bill) { console.log('Bill not found'); return(null); } return([billLocation, bill] as [BillingLocation, Bill]); }) */ export const fetchBillById = async (locationID: string, billID: string, includeAttachmentBinary: boolean = false) => { unstable_noStore(); const dbClient = await getDbClient(); // don't include the attachment binary data in the response // if the attachment binary data is not needed const projection = includeAttachmentBinary ? {} : { "bills.attachment.fileContentsBase64": 0, }; // 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`); return (null); } // find a bill with the given billID const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); if (!bill) { console.log('Bill not found'); return (null); } 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) => { unstable_noStore(); 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 }, { 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") .find({ userId, name: location.name, $or: [ { "yearMonth.year": { $gt: year } }, { "yearMonth.year": year, "yearMonth.month": { $gt: month } } ], "bills.name": bill.name }, { projection: { _id: 1 } }) .toArray(); // Delete the bill from all subsequent locations (by name) const updateOperations = subsequentLocations.map(loc => ({ updateOne: { filter: { _id: loc._id, userId }, update: { $pull: { bills: { name: bill.name } as Partial } } } })); // Also delete from current location (by ID for precision) updateOperations.push({ updateOne: { filter: { _id: locationID, userId }, update: { $pull: { bills: { _id: billID } } } } }); // Execute all delete operations if (updateOperations.length > 0) { await dbClient.collection("lokacije").bulkWrite(updateOperations); } } } } else { // Delete only from current location (original behavior) await dbClient.collection("lokacije").updateOne( { _id: locationID, // find a location with the given locationID userId // make sure that the location belongs to the user }, { // remove the bill with the given billID $pull: { bills: { _id: billID } } }); } const locale = await getLocale(); await gotoHomeWithMessage(locale, 'billDeleted'); // This return is needed for TypeScript, but won't be reached due to redirect return { message: null, errors: undefined, }; }); /** * Uploads proof of payment for the given bill * SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP * * @param shareId - Combined location ID + checksum (40 chars) * @param billID - The bill ID to attach proof of payment to * @param formData - Form data containing the PDF file * @param ipAddress - Optional IP address for rate limiting */ export const uploadProofOfPayment = async ( shareId: string, billID: string, formData: FormData, ipAddress?: string ): Promise<{ success: boolean; error?: string }> => { unstable_noStore(); try { // 1. EXTRACT AND VALIDATE CHECKSUM (stateless, fast) const extracted = extractShareId(shareId); if (!extracted) { return { success: false, error: 'Invalid share link' }; } const { locationId: locationID, checksum } = extracted; if (!validateShareChecksum(locationID, checksum)) { return { success: false, error: 'Invalid share link' }; } // 2. RATE LIMITING (per IP) if (ipAddress) { const rateLimit = checkUploadRateLimit(ipAddress); if (!rateLimit.allowed) { return { success: false, error: `Too many uploads. Try again in ${Math.ceil(rateLimit.resetIn / 60)} minutes.` }; } } // 3. DATABASE VALIDATION const dbClient = await getDbClient(); const location = await dbClient.collection("lokacije").findOne( { _id: locationID }, { projection: { userId: 1, bills: 1, shareTTL: 1 } } ); if (!location || !location.userId) { return { success: false, error: 'Invalid request' }; } // Check sharing is active and not expired if (!location.shareTTL || new Date() > location.shareTTL) { return { success: false, error: 'This content is no longer shared' }; } // Verify bill exists in location const bill = location.bills.find(b => b._id === billID); if (!bill) { return { success: false, error: 'Invalid request' }; } // Check if proof of payment already uploaded if (bill.proofOfPayment?.uploadedAt) { return { success: false, error: 'Proof of payment already uploaded for this bill' }; } // 4. FILE VALIDATION const file = formData.get('proofOfPayment') as File; if (!file || file.size === 0) { return { success: false, error: 'No file provided' }; } // Validate PDF content (magic bytes, not just MIME type) const pdfValidation = await validatePdfFile(file); if (!pdfValidation.valid) { return { success: false, error: pdfValidation.error }; } // 5. SERIALIZE & STORE FILE const attachment = await serializeAttachment(file); if (!attachment) { return { success: false, error: 'Failed to process file' }; } // 6. UPDATE DATABASE await dbClient.collection("lokacije").updateOne( { _id: locationID }, { $set: { "bills.$[elem].proofOfPayment": attachment } }, { arrayFilters: [{ "elem._id": { $eq: billID } }] } ); // 7. CLEANUP EXPIRED SHARES (integrated, no cron needed) await cleanupExpiredShares(dbClient); // 8. REVALIDATE CACHE revalidatePath(`/share/location/${shareId}`, 'page'); return { success: true }; } catch (error: any) { console.error('Upload error:', error); return { success: false, error: 'Upload failed. Please try again.' }; } }; /** * Clean up expired shares during upload processing * Removes shareTTL and shareFirstVisitedAt from expired locations */ async function cleanupExpiredShares(dbClient: any) { const now = new Date(); await dbClient.collection("lokacije").updateMany( { shareTTL: { $lt: now } }, { $unset: { shareTTL: "", shareFirstVisitedAt: "" } } ); }