Files
evidencija-rezija/app/lib/actions/locationActions.ts
Knee Cola 0f8b5678f4 Fix client-side cache staleness after proof of payment upload
Added cache revalidation to ensure ViewLocationCard reflects uploaded
proof of payment when navigating back from ViewBillCard:

- Server-side: Added revalidatePath() to upload actions in billActions
  and locationActions to invalidate Next.js server cache
- Client-side: Added router.refresh() calls in ViewBillCard and
  ViewLocationCard to refresh client router cache after successful upload

This maintains the current UX (no redirect on upload) while ensuring
fresh data is displayed on navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 16:57:00 +01:00

701 lines
26 KiB
TypeScript

'use server';
import { z } from 'zod';
import { getDbClient } from '../dbClient';
import { BillingLocation, FileAttachment, YearMonth } from '../db-types';
import { ObjectId } from 'mongodb';
import { withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from '../types/next-auth';
import { gotoHomeWithMessage } from './navigationActions';
import { unstable_noStore, revalidatePath } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server";
export type State = {
errors?: {
locationName?: string[];
tenantName?: string[];
tenantStreet?: string[];
tenantTown?: string[];
autoBillFwd?: string[];
tenantEmail?: string[];
billFwdStrategy?: string[];
rentDueNotification?: string[];
rentDueDay?: string[];
rentAmount?: string[];
};
message?:string | null;
};
/**
* Schema for validating location 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(),
locationName: z.coerce.string().min(1, t("location-name-required")),
tenantPaymentMethod: z.enum(["none", "iban", "revolut"]).optional().nullable(),
proofOfPaymentType: z.enum(["none", "combined", "per-bill"]).optional().nullable(),
tenantName: z.string().max(30).optional().nullable(),
tenantStreet: z.string().max(27).optional().nullable(),
tenantTown: z.string().max(27).optional().nullable(),
autoBillFwd: z.boolean().optional().nullable(),
tenantEmail: z.string().email(t("tenant-email-invalid")).optional().or(z.literal("")).nullable(),
billFwdStrategy: z.enum(["when-payed", "when-attached"]).optional().nullable(),
rentDueNotification: z.boolean().optional().nullable(),
rentDueDay: z.coerce.number().min(1).max(31).optional().nullable(),
rentAmount: z.coerce.number().int(t("rent-amount-integer")).positive(t("rent-amount-positive")).optional().nullable(),
addToSubsequentMonths: z.boolean().optional().nullable(),
updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(),
})
// dont include the _id field in the response
.omit({ _id: true })
// Add conditional validation: if `tenantPaymentMethod` is "iban", tenant fields are required
.refine((data) => {
if (data.tenantPaymentMethod === "iban") {
return !!data.tenantName && data.tenantName.trim().length > 0;
}
return true;
}, {
message: t("tenant-name-required"),
path: ["tenantName"],
})
.refine((data) => {
if (data.tenantPaymentMethod === "iban") {
return !!data.tenantStreet && data.tenantStreet.trim().length > 0;
}
return true;
}, {
message: t("tenant-street-required"),
path: ["tenantStreet"],
})
.refine((data) => {
if (data.tenantPaymentMethod === "iban") {
return !!data.tenantTown && data.tenantTown.trim().length > 0;
}
return true;
}, {
message: t("tenant-town-required"),
path: ["tenantTown"],
})
.refine((data) => {
if (data.autoBillFwd || data.rentDueNotification) {
return !!data.tenantEmail && data.tenantEmail.trim().length > 0;
}
return true;
}, {
message: t("tenant-email-required"),
path: ["tenantEmail"],
})
.refine((data) => {
if (data.rentDueNotification) {
return !!data.rentAmount && data.rentAmount > 0;
}
return true;
}, {
message: t("rent-amount-required"),
path: ["rentAmount"],
});
/**
* Server-side action which adds or updates a bill
* @param locationId location of the bill
* @param prevState previous state of the form
* @param formData form data
* @returns
*/
export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locationId: string | undefined, yearMonth: YearMonth | undefined, prevState:State, formData: FormData) => {
unstable_noStore();
const t = await getTranslations("location-edit-form.validation");
const validatedFields = FormSchema(t).safeParse({
locationName: formData.get('locationName'),
tenantPaymentMethod: formData.get('tenantPaymentMethod') as "none" | "iban" | "revolut" | undefined,
proofOfPaymentType: formData.get('proofOfPaymentType') as "none" | "combined" | "per-bill" | undefined,
tenantName: formData.get('tenantName') || null,
tenantStreet: formData.get('tenantStreet') || null,
tenantTown: formData.get('tenantTown') || null,
autoBillFwd: formData.get('autoBillFwd') === 'on',
tenantEmail: formData.get('tenantEmail') || null,
billFwdStrategy: formData.get('billFwdStrategy') as "when-payed" | "when-attached" | undefined,
rentDueNotification: formData.get('rentDueNotification') === 'on',
rentDueDay: formData.get('rentDueDay') || null,
rentAmount: formData.get('rentAmount') || null,
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined,
});
// If form validation fails, return errors early. Otherwise, continue...
if(!validatedFields.success) {
return({
errors: validatedFields.error.flatten().fieldErrors,
message: t("validation-failed"),
});
}
const {
locationName,
tenantPaymentMethod,
proofOfPaymentType,
tenantName,
tenantStreet,
tenantTown,
autoBillFwd,
tenantEmail,
billFwdStrategy,
rentDueNotification,
rentDueDay,
rentAmount,
addToSubsequentMonths,
updateScope,
} = validatedFields.data;
// update the bill in the mongodb
const dbClient = await getDbClient();
const { id: userId, email: userEmail } = user;
if(locationId) {
// Get the current location first to find its name
const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationId, userId }, { projection: { bills: 0 } });
if (!currentLocation) {
return {
message: "Location not found",
errors: undefined,
};
}
// Handle different update scopes
if (updateScope === "current" || !updateScope) {
// Update only the current location (default behavior)
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationId,
userId
},
{
$set: {
name: locationName,
tenantPaymentMethod: tenantPaymentMethod || "none",
proofOfPaymentType: proofOfPaymentType || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
}
}
);
} else if (updateScope === "subsequent") {
// Update current and all subsequent months
await dbClient.collection<BillingLocation>("lokacije").updateMany(
{
userId,
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: currentLocation.yearMonth.year } },
{
"yearMonth.year": currentLocation.yearMonth.year,
"yearMonth.month": { $gte: currentLocation.yearMonth.month }
}
]
},
{
$set: {
name: locationName,
tenantPaymentMethod: tenantPaymentMethod || "none",
proofOfPaymentType: proofOfPaymentType || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
}
}
);
} else if (updateScope === "all") {
// Update all locations with the same name across all months
await dbClient.collection<BillingLocation>("lokacije").updateMany(
{
userId,
name: currentLocation.name
},
{
$set: {
name: locationName,
tenantPaymentMethod: tenantPaymentMethod || "none",
proofOfPaymentType: proofOfPaymentType || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
}
}
);
}
} else if(yearMonth) {
// Always add location to the specified month
await dbClient.collection<BillingLocation>("lokacije").insertOne({
_id: (new ObjectId()).toHexString(),
userId,
userEmail,
name: locationName,
notes: null,
tenantPaymentMethod: tenantPaymentMethod || "none",
proofOfPaymentType: proofOfPaymentType || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
yearMonth: yearMonth,
bills: [],
});
// If addToSubsequentMonths is enabled, add to all subsequent months
if (addToSubsequentMonths) {
// Find all subsequent months that exist in the database
const subsequentMonths = await dbClient.collection<BillingLocation>("lokacije")
.aggregate([
{
$match: {
userId,
$or: [
{ "yearMonth.year": { $gt: yearMonth.year } },
{
"yearMonth.year": yearMonth.year,
"yearMonth.month": { $gt: yearMonth.month }
}
]
}
},
{
$group: {
_id: {
year: "$yearMonth.year",
month: "$yearMonth.month"
}
}
},
{
$project: {
_id: 0,
year: "$_id.year",
month: "$_id.month"
}
},
{
$sort: {
year: 1,
month: 1
}
}
])
.toArray();
// For each subsequent month, check if location with same name already exists
const locationsToInsert = [];
for (const monthData of subsequentMonths) {
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({
userId,
name: locationName,
"yearMonth.year": monthData.year,
"yearMonth.month": monthData.month
}, { projection: { bills: 0 } });
// Only add if location with same name doesn't already exist in that month
if (!existingLocation) {
locationsToInsert.push({
_id: (new ObjectId()).toHexString(),
userId,
userEmail,
name: locationName,
notes: null,
tenantPaymentMethod: tenantPaymentMethod || "none",
proofOfPaymentType: proofOfPaymentType || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
yearMonth: { year: monthData.year, month: monthData.month },
bills: [],
});
}
}
// Insert all new locations at once if any
if (locationsToInsert.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").insertMany(locationsToInsert);
}
}
}
// Redirect to home page with year and month parameters, including success message
if (yearMonth) {
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'locationSaved', yearMonth);
}
// This return is needed for TypeScript, but won't be reached due to redirect
return {
message: null,
errors: undefined,
};
});
export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:number) => {
unstable_noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
// fetch all locations for the given year
const locations = await dbClient.collection<BillingLocation>("lokacije")
.aggregate<BillingLocation>([
{
$match: {
userId,
"yearMonth.year": year,
},
},
// DUPLICATION of block below ... probably added by AI {
// DUPLICATION of block below ... probably added by AI $addFields: {
// DUPLICATION of block below ... probably added by AI bills: {
// DUPLICATION of block below ... probably added by AI $map: {
// DUPLICATION of block below ... probably added by AI input: "$bills",
// DUPLICATION of block below ... probably added by AI as: "bill",
// DUPLICATION of block below ... probably added by AI in: {
// DUPLICATION of block below ... probably added by AI _id: "$$bill._id",
// DUPLICATION of block below ... probably added by AI name: "$$bill.name",
// DUPLICATION of block below ... probably added by AI paid: "$$bill.paid",
// DUPLICATION of block below ... probably added by AI billedTo: "$$bill.billedTo",
// DUPLICATION of block below ... probably added by AI payedAmount: "$$bill.payedAmount",
// DUPLICATION of block below ... probably added by AI hasAttachment: { $ne: ["$$bill.attachment", null] },
// DUPLICATION of block below ... probably added by AI },
// DUPLICATION of block below ... probably added by AI },
// DUPLICATION of block below ... probably added by AI },
// DUPLICATION of block below ... probably added by AI },
// DUPLICATION of block below ... probably added by AI },
{
$addFields: {
_id: { $toString: "$_id" },
bills: {
$map: {
input: "$bills",
as: "bill",
in: {
_id: { $toString: "$$bill._id" },
name: "$$bill.name",
paid: "$$bill.paid",
billedTo: "$$bill.billedTo",
payedAmount: "$$bill.payedAmount",
hasAttachment: { $ne: ["$$bill.attachment", null] },
proofOfPayment: "$$bill.proofOfPayment",
},
},
}
}
},
{
$project: {
"_id": 1,
// "userId": 0,
// "userEmail": 0,
"name": 1,
// "notes": 0,
// "yearMonth": 1,
"yearMonth.year": 1,
"yearMonth.month": 1,
"bills._id": 1,
"bills.name": 1,
"bills.paid": 1,
"bills.hasAttachment": 1,
"bills.payedAmount": 1,
"bills.proofOfPayment.uploadedAt": 1,
"seenByTenantAt": 1,
// "bills.attachment": 0,
// "bills.notes": 0,
// "bills.hub3aText": 1,
// project only file name - leave out file content so that
// less data is transferred to the client
"utilBillsProofOfPayment.fileName": 1,
"utilBillsProofOfPayment.uploadedAt": 1,
},
},
{
$sort: {
"yearMonth.year": -1,
"yearMonth.month": -1,
name: 1,
},
},
])
.toArray();
return(locations)
})
/*
ova metoda je zamijenjena sa jednostavnijom `fetchLocationById`, koja brže radi jer ne provjerava korisnika
export const fetchLocationByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string) => {
unstable_noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne(
{ _id: locationID, userId },
{
projection: {
// don't include the attachment binary data in the response
"bills.attachment.fileContentsBase64": 0,
},
}
);
if(!billLocation) {
console.log(`Location ${locationID} not found`);
return(null);
}
return(billLocation);
});
*/
export const fetchLocationById = async (locationID:string) => {
unstable_noStore();
const dbClient = await getDbClient();
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne(
{ _id: locationID },
{
projection: {
// don't include the attachment binary data in the response
"bills.attachment.fileContentsBase64": 0,
"utilBillsProofOfPayment.fileContentsBase64": 0,
},
}
);
if(!billLocation) {
console.log(`Location ${locationID} not found`);
return(null);
}
return(billLocation);
};
export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth, _prevState:any, formData: FormData) => {
unstable_noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
const deleteInSubsequentMonths = formData.get('deleteInSubsequentMonths') === 'on';
if (deleteInSubsequentMonths) {
// Get the location name first to find all locations with the same name
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID, userId }, { projection: { name: 1 } });
if (location) {
// Delete all locations with the same name in current and subsequent months
await dbClient.collection<BillingLocation>("lokacije").deleteMany({
userId,
name: location.name,
$or: [
{ "yearMonth.year": { $gt: yearMonth.year } },
{
"yearMonth.year": yearMonth.year,
"yearMonth.month": { $gte: yearMonth.month }
}
]
});
}
} else {
// Delete only the specific location (current behavior)
await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
}
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'locationDeleted');
// This return is needed for TypeScript, but won't be reached due to redirect
return {
message: null,
errors: undefined,
};
})
/**
* Sets the `seenByTenantAt` flag to true for a specific location.
*
* This function marks a location as viewed by the tenant. It first checks if the flag
* is already set to true to avoid unnecessary database updates.
*
* @param {string} locationID - The ID of the location to update
* @returns {Promise<void>}
*
* @example
* await setseenByTenantAt("507f1f77bcf86cd799439011");
*/
export const setSeenByTenantAt = async (locationID: string): Promise<void> => {
const dbClient = await getDbClient();
// First check if the location exists and if seenByTenantAt is already true
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID });
// If location doesn't exist or seenByTenantAt is already true, no update needed
if (!location || location.seenByTenantAt) {
return;
}
// Update the location to mark it as seen by tenant
await dbClient.collection<BillingLocation>("lokacije")
.updateOne(
{ _id: locationID },
{ $set: { seenByTenantAt: new Date() } }
);
}
/**
* Serializes a file attachment to be stored in the database
* @param file - The file to serialize
* @returns BillAttachment object or null if file is invalid
*/
const serializeAttachment = async (file: File | null):Promise<FileAttachment | null> => {
if (!file) {
return null;
}
const {
name: fileName,
size: fileSize,
type: fileType,
lastModified: fileLastModified,
} = file;
if(!fileName || fileName === 'undefined' || fileSize === 0) {
return null;
}
// Convert file contents to base64 for database storage
const fileContents = await file.arrayBuffer();
const fileContentsBase64 = Buffer.from(fileContents).toString('base64');
return {
fileName,
fileSize,
fileType,
fileLastModified,
fileContentsBase64,
uploadedAt: new Date(),
};
}
/**
* Uploads a single proof of payment for all utility bills in a location
* @param locationID - The ID of the location
* @param formData - FormData containing the file
* @returns Promise with success status
*/
export const uploadUtilBillsProofOfPayment = async (locationID: 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.size > 0 && file.type !== 'application/pdf') {
return { success: false, error: 'Only PDF files are accepted' };
}
// check if attachment already exists for the location
const dbClient = await getDbClient();
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1 } });
if (existingLocation?.utilBillsProofOfPayment) {
return { success: false, error: 'An attachment already exists for this location' };
}
const attachment = await serializeAttachment(file);
if (!attachment) {
return { success: false, error: 'Invalid file' };
}
// Update the location with the attachment
await dbClient.collection<BillingLocation>("lokacije")
.updateOne(
{ _id: locationID },
{ $set: {
utilBillsProofOfPayment: {
...attachment
},
} }
);
// Invalidate the location view cache
revalidatePath(`/share/location/${locationID}`, 'page');
return { success: true };
} catch (error: any) {
console.error('Error uploading util bills proof of payment:', error);
return { success: false, error: error.message || 'Upload failed' };
}
}