Added explicit return statements after redirect calls in all server actions to satisfy TypeScript type checking. These returns won't be reached due to redirects but are needed for type safety. Fixed in: - userProfileActions.ts (updateUserProfile) - locationActions.ts (updateOrAddLocation, deleteLocationById) - billActions.ts (updateOrAddBill, deleteBill) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
'use server';
|
|
|
|
import { z } from 'zod';
|
|
import { getDbClient } from '../dbClient';
|
|
import { Bill, BilledTo, BillAttachment, 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';
|
|
|
|
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) => {
|
|
|
|
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,
|
|
} as BillAttachment);
|
|
}
|
|
|
|
/**
|
|
* 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) => {
|
|
|
|
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 barcodeImage = formData.get('barcodeImage')?.valueOf() as string;
|
|
|
|
// update the bill in the mongodb
|
|
const dbClient = await getDbClient();
|
|
|
|
const billAttachment = await serializeAttachment(formData.get('billAttachment') as File);
|
|
|
|
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].barcodeImage": barcodeImage,
|
|
|
|
}: {
|
|
"bills.$[elem].name": billName,
|
|
"bills.$[elem].paid": billPaid,
|
|
"bills.$[elem].billedTo": billedTo,
|
|
"bills.$[elem].notes": billNotes,
|
|
"bills.$[elem].payedAmount": payedAmount,
|
|
"bills.$[elem].barcodeImage": barcodeImage,
|
|
};
|
|
|
|
// find a location with the given locationID
|
|
const post = 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,
|
|
barcodeImage,
|
|
};
|
|
|
|
// 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
|
|
},
|
|
{
|
|
$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<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,
|
|
barcodeImage: 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');
|
|
}
|
|
|
|
// 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) => {
|
|
|
|
|
|
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) => {
|
|
|
|
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,
|
|
};
|
|
}); |