Added tenantEmail and tenantEmailStatus fields to the MongoDB projection in fetchAllLocations() so LocationCard can display email status indicators. Previously these fields were always undefined in LocationCard because they weren't included in the aggregation pipeline's $project stage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
871 lines
32 KiB
TypeScript
871 lines
32 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";
|
|
import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum';
|
|
import { validatePdfFile } from '../validators/pdfValidator';
|
|
import { checkUploadRateLimit } from '../uploadRateLimiter';
|
|
|
|
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,
|
|
"tenantEmail": 1,
|
|
"tenantEmailStatus": 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
|
|
* SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP
|
|
*
|
|
* @param shareId - Combined location ID + checksum (40 chars)
|
|
* @param formData - FormData containing the PDF file
|
|
* @param ipAddress - Optional IP address for rate limiting
|
|
* @returns Promise with success status
|
|
*/
|
|
export const uploadUtilBillsProofOfPayment = async (
|
|
shareId: 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) {
|
|
console.log('shareID extraction failed');
|
|
return { success: false, error: 'Invalid share link' };
|
|
}
|
|
|
|
const { locationId: locationID, checksum } = extracted;
|
|
|
|
if (!validateShareChecksum(locationID, checksum)) {
|
|
console.log('shareID checksum validation failed');
|
|
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, utilBillsProofOfPayment: 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' };
|
|
}
|
|
|
|
// Check if proof of payment already uploaded
|
|
if (location.utilBillsProofOfPayment) {
|
|
return { success: false, error: 'Proof of payment already uploaded for this location' };
|
|
}
|
|
|
|
// 4. FILE VALIDATION
|
|
const file = formData.get('utilBillsProofOfPayment') 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: {
|
|
utilBillsProofOfPayment: attachment
|
|
} }
|
|
);
|
|
|
|
// 7. 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.' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate/activate share link for location
|
|
* Called when owner clicks "Share" button
|
|
* Sets shareTTL to 10 days from now
|
|
*/
|
|
export const generateShareLink = withUser(
|
|
async (user: AuthenticatedUser, locationId: string) => {
|
|
|
|
const { id: userId } = user;
|
|
const dbClient = await getDbClient();
|
|
|
|
// Verify ownership
|
|
const location = await dbClient.collection<BillingLocation>("lokacije").findOne({
|
|
_id: locationId,
|
|
userId
|
|
});
|
|
|
|
if (!location) {
|
|
return { error: 'Location not found' };
|
|
}
|
|
|
|
// Calculate TTL (10 days from now, configurable)
|
|
const initialDays = parseInt(process.env.SHARE_TTL_INITIAL_DAYS || '10', 10);
|
|
const shareTTL = new Date(Date.now() + initialDays * 24 * 60 * 60 * 1000);
|
|
|
|
// Activate sharing by setting TTL
|
|
await dbClient.collection<BillingLocation>("lokacije").updateOne(
|
|
{ _id: locationId },
|
|
{
|
|
$set: { shareTTL },
|
|
$unset: { shareFirstVisitedAt: "" } // Reset first visit tracking
|
|
}
|
|
);
|
|
|
|
// Generate combined share ID (locationId + checksum)
|
|
const shareId = generateShareId(locationId);
|
|
|
|
// Build share URL
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
|
const shareUrl = `${baseUrl}/share/location/${shareId}`;
|
|
|
|
return { shareUrl };
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Validate share link and update TTL on first visit
|
|
* Called when tenant visits share link
|
|
*
|
|
* SECURITY:
|
|
* 1. Extracts locationId and checksum from combined shareId
|
|
* 2. Validates checksum (stateless, prevents enumeration)
|
|
* 3. Checks TTL in database (time-based access control)
|
|
* 4. Marks first visit and resets TTL to 1 hour
|
|
*
|
|
* @param shareId - Combined ID (locationId + checksum, 40 chars)
|
|
* @returns Object with validation result and extracted locationId
|
|
*/
|
|
export async function validateShareAccess(
|
|
shareId: string
|
|
): Promise<{ valid: boolean; locationId?: string; error?: string }> {
|
|
|
|
// 1. Extract locationId and checksum from combined ID
|
|
const extracted = extractShareId(shareId);
|
|
if (!extracted) {
|
|
console.log('shareID extraction failed');
|
|
return { valid: false, error: 'Invalid share link' };
|
|
}
|
|
|
|
const { locationId, checksum } = extracted;
|
|
|
|
// 2. Validate checksum FIRST (before DB query - stateless validation)
|
|
if (!validateShareChecksum(locationId, checksum)) {
|
|
console.log('shareID checksum validation failed');
|
|
return { valid: false, error: 'Invalid share link' };
|
|
}
|
|
|
|
// 3. Check TTL in database
|
|
const dbClient = await getDbClient();
|
|
const location = await dbClient.collection<BillingLocation>("lokacije").findOne(
|
|
{ _id: locationId },
|
|
{ projection: { shareTTL: 1, shareFirstVisitedAt: 1 } }
|
|
);
|
|
|
|
if (!location) {
|
|
console.log('Location not found for shareID');
|
|
return { valid: false, error: 'Invalid share link' };
|
|
}
|
|
|
|
// 4. Check if sharing is enabled
|
|
if (!location.shareTTL) {
|
|
return { valid: false, error: 'This content is no longer shared' };
|
|
}
|
|
|
|
// 5. Check if TTL expired
|
|
const now = new Date();
|
|
if (now > location.shareTTL) {
|
|
// Clean up expired share
|
|
await dbClient.collection<BillingLocation>("lokacije").updateOne(
|
|
{ _id: locationId },
|
|
{ $unset: { shareTTL: "", shareFirstVisitedAt: "" } }
|
|
);
|
|
|
|
return { valid: false, error: 'This content is no longer shared' };
|
|
}
|
|
|
|
// 6. Mark first visit if applicable (resets TTL to 1 hour)
|
|
if (!location.shareFirstVisitedAt) {
|
|
const visitHours = parseInt(process.env.SHARE_TTL_AFTER_VISIT_HOURS || '1', 10);
|
|
const newTTL = new Date(Date.now() + visitHours * 60 * 60 * 1000);
|
|
|
|
await dbClient.collection<BillingLocation>("lokacije").updateOne(
|
|
{
|
|
_id: locationId,
|
|
shareFirstVisitedAt: null // Only update if not already set
|
|
},
|
|
{
|
|
$set: {
|
|
shareFirstVisitedAt: new Date(),
|
|
shareTTL: newTTL
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
return { valid: true, locationId };
|
|
} |