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>
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
'use server';
|
|
|
|
import { z } from 'zod';
|
|
import { getDbClient } from '../dbClient';
|
|
import { 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 { unstable_noStore as noStore } from 'next/cache';
|
|
import { IntlTemplateFn } from '@/app/i18n';
|
|
import { getTranslations, getLocale } from "next-intl/server";
|
|
|
|
export type State = {
|
|
errors?: {
|
|
locationName?: string[];
|
|
locationNotes?: 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")),
|
|
locationNotes: z.string(),
|
|
addToSubsequentMonths: z.boolean().optional().nullable(),
|
|
updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(),
|
|
})
|
|
// dont include the _id field in the response
|
|
.omit({ _id: true });
|
|
|
|
/**
|
|
* 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) => {
|
|
|
|
noStore();
|
|
|
|
const t = await getTranslations("location-edit-form.validation");
|
|
|
|
const validatedFields = FormSchema(t).safeParse({
|
|
locationName: formData.get('locationName'),
|
|
locationNotes: formData.get('locationNotes'),
|
|
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: "Missing Fields",
|
|
});
|
|
}
|
|
|
|
const {
|
|
locationName,
|
|
locationNotes,
|
|
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,
|
|
notes: locationNotes,
|
|
}
|
|
}
|
|
);
|
|
} 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,
|
|
notes: locationNotes,
|
|
}
|
|
}
|
|
);
|
|
} 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,
|
|
notes: locationNotes,
|
|
}
|
|
}
|
|
);
|
|
}
|
|
} 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: locationNotes,
|
|
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: locationNotes,
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
if(yearMonth) {
|
|
const locale = await getLocale();
|
|
await gotoHomeWithMessage(locale, 'locationSaved');
|
|
}
|
|
|
|
// 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) => {
|
|
|
|
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,
|
|
},
|
|
},
|
|
{
|
|
$addFields: {
|
|
bills: {
|
|
$map: {
|
|
input: "$bills",
|
|
as: "bill",
|
|
in: {
|
|
_id: "$$bill._id",
|
|
name: "$$bill.name",
|
|
paid: "$$bill.paid",
|
|
billedTo: "$$bill.billedTo",
|
|
payedAmount: "$$bill.payedAmount",
|
|
hasAttachment: { $ne: ["$$bill.attachment", null] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$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: "$$bill.hasAttachment",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
"_id": 1,
|
|
// "userId": 0,
|
|
// "userEmail": 0,
|
|
"name": 1,
|
|
// "notes": 0,
|
|
// "yearMonth": 1,
|
|
"yearMonth.year": 1,
|
|
"yearMonth.month": 1,
|
|
"bills": 1,
|
|
// "bills.attachment": 0,
|
|
// "bills.notes": 0,
|
|
// "bills.barcodeImage": 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) => {
|
|
|
|
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) => {
|
|
|
|
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,
|
|
},
|
|
}
|
|
);
|
|
|
|
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) => {
|
|
|
|
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,
|
|
};
|
|
}) |