Modified bill forwarding logic to only consider bills marked as "billed to tenant" when determining if all bills are ready for forwarding. Bills billed to landlord should not affect the forwarding trigger. Changes: - Filter out landlord bills before checking if all bills are paid/attached - Improved status check to explicitly look for "pending" or "sent" status - Added edge case handling when no tenant bills exist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
746 lines
25 KiB
TypeScript
746 lines
25 KiB
TypeScript
'use server';
|
|
|
|
import { z } from 'zod';
|
|
import { getDbClient } from '../dbClient';
|
|
import { Bill, BilledTo, FileAttachment, BillingLocation } from '@evidencija-rezija/shared-code';
|
|
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 '@evidencija-rezija/shared-code';
|
|
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
|
|
|
|
}),
|
|
});
|
|
|
|
/**
|
|
* Checks if billFwdStatus should be updated to "pending" based on attachment conditions
|
|
* @param location - The billing location containing the bill
|
|
* @param currentBillId - The ID of the bill being updated (to exclude from check)
|
|
* @param hasNewAttachment - Whether a new attachment is being added
|
|
* @returns true if billFwdStatus should be set to "pending"
|
|
*/
|
|
const shouldUpdateBillFwdStatusWhenAttached = (
|
|
location: BillingLocation,
|
|
currentBillId: string | undefined,
|
|
hasNewAttachment: boolean
|
|
): boolean => {
|
|
// Only proceed if a new attachment is being added
|
|
if (!hasNewAttachment) {
|
|
return false;
|
|
}
|
|
|
|
// Check billFwdEnabled is true
|
|
if (location.billFwdEnabled !== true) {
|
|
return false;
|
|
}
|
|
|
|
// Check billFwdStrategy is "when-attached"
|
|
if (location.billFwdStrategy !== "when-attached") {
|
|
return false;
|
|
}
|
|
|
|
// Check bills have already been sent or are pending -> don't sent them again
|
|
if (location.billFwdStatus === "pending" || location.billFwdStatus === "sent") {
|
|
return false;
|
|
}
|
|
|
|
// Check if ALL other bills have attachments
|
|
const otherBills = location.bills.filter(bill => bill._id !== currentBillId);
|
|
// filter only bills billed to tenant
|
|
// because bills billed to landlord should not trigger forwarding
|
|
const billsPayedByTenant = otherBills.filter(bill => bill.billedTo === BilledTo.Tenant);
|
|
|
|
if(billsPayedByTenant.length === 0) {
|
|
// No other bills billed to tenant exist, so do not trigger forwarding
|
|
return false;
|
|
}
|
|
|
|
const allOtherBillsHaveAttachments = billsPayedByTenant.every(bill => bill.attachment !== null);
|
|
|
|
return allOtherBillsHaveAttachments;
|
|
};
|
|
|
|
/**
|
|
* Checks if billFwdStatus should be updated to "pending" based on paid status
|
|
* @param location - The billing location containing the bill
|
|
* @param currentBillId - The ID of the bill being updated (to exclude from check)
|
|
* @param isPaid - Whether the current bill is being marked as paid
|
|
* @returns true if billFwdStatus should be set to "pending"
|
|
*/
|
|
const shouldUpdateBillFwdStatusWhenPayed = (
|
|
location: BillingLocation,
|
|
currentBillId: string | undefined,
|
|
isPaid: boolean
|
|
): boolean => {
|
|
// Only proceed if the bill is being marked as paid
|
|
if (!isPaid) {
|
|
return false;
|
|
}
|
|
|
|
// Check billFwdEnabled is true
|
|
if (location.billFwdEnabled !== true) {
|
|
return false;
|
|
}
|
|
|
|
// Check billFwdStrategy is "when-payed"
|
|
if (location.billFwdStrategy !== "when-payed") {
|
|
return false;
|
|
}
|
|
|
|
// Check bills have already been sent or are pending -> don't sent them again
|
|
if (location.billFwdStatus === "pending" || location.billFwdStatus === "sent") {
|
|
return false;
|
|
}
|
|
|
|
// Check if ALL other bills are paid
|
|
const otherBills = location.bills.filter(bill => bill._id !== currentBillId);
|
|
// filter only bills billed to tenant
|
|
// because bills billed to landlord should not trigger forwarding
|
|
const billsPayedByTenant = otherBills.filter(bill => bill.billedTo === BilledTo.Tenant);
|
|
|
|
if(billsPayedByTenant.length === 0) {
|
|
// No other bills billed to tenant exist, so do not trigger forwarding
|
|
return false;
|
|
}
|
|
|
|
const allOtherBillsPaid = billsPayedByTenant.every(bill => bill.paid === true);
|
|
|
|
return allOtherBillsPaid;
|
|
};
|
|
|
|
/**
|
|
* converts the file to a format stored in the database
|
|
* @param billAttachment
|
|
* @returns
|
|
*/
|
|
const serializeAttachment = async (billAttachment: File | null): Promise<FileAttachment | null> => {
|
|
|
|
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 hub3aTextEncoded = formData.get('hub3aText')?.valueOf() as string;
|
|
const hub3aText = hub3aTextEncoded ? decodeURIComponent(hub3aTextEncoded) : undefined;
|
|
|
|
// 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);
|
|
|
|
// Fetch the location to check billFwdStatus conditions
|
|
const location = await dbClient.collection<BillingLocation>("lokacije").findOne({
|
|
_id: locationId,
|
|
userId
|
|
});
|
|
|
|
if (!location) {
|
|
return { success: false, error: 'Location not found' };
|
|
}
|
|
|
|
// Check if we should update billFwdStatus to "pending"
|
|
const shouldSetFwdPendingWhenAttached = shouldUpdateBillFwdStatusWhenAttached(location, billId, billAttachment !== null);
|
|
const shouldSetFwdPendingWhenPayed = shouldUpdateBillFwdStatusWhenPayed(location, billId, billPaid);
|
|
const shouldSetFwdPending = shouldSetFwdPendingWhenAttached || shouldSetFwdPendingWhenPayed;
|
|
|
|
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,
|
|
};
|
|
|
|
// Add billFwdStatus if needed
|
|
if (shouldSetFwdPending) {
|
|
(mongoDbSet as any).billFwdStatus = "pending";
|
|
}
|
|
|
|
// update bill in given location with the given locationID
|
|
await dbClient.collection<BillingLocation>("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,
|
|
};
|
|
|
|
// Build update operation
|
|
const updateOp: any = {
|
|
$push: {
|
|
bills: newBill
|
|
}
|
|
};
|
|
|
|
// Add billFwdStatus update if needed
|
|
if (shouldSetFwdPending) {
|
|
updateOp.$set = {
|
|
billFwdStatus: "pending"
|
|
};
|
|
}
|
|
|
|
// Add to current location
|
|
await dbClient.collection<BillingLocation>("lokacije").updateOne(
|
|
{
|
|
_id: locationId, // find a location with the given locationID
|
|
userId // make sure that the location belongs to the user
|
|
},
|
|
updateOp);
|
|
|
|
// 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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<Bill>
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
// 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<BillingLocation>("lokacije").bulkWrite(updateOperations);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Delete only from current location (original behavior)
|
|
await dbClient.collection<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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: "" } }
|
|
);
|
|
} |