'use server'; import { z } from 'zod'; import { getDbClient } from '../dbClient'; import { BillingLocation, FileAttachment, YearMonth, EmailStatus, BillsNotificationStrategy } 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 { unstable_noStore, revalidatePath } from 'next/cache'; import { IntlTemplateFn } from '@/app/i18n'; import { getTranslations, getLocale } from "next-intl/server"; import { generateShareId, extractShareId, validateShareChecksum } from '@evidencija-rezija/shared-code'; import { validatePdfFile } from '../validators/pdfValidator'; import { checkUploadRateLimit } from '../uploadRateLimiter'; export type State = { errors?: { locationName?: string[]; tenantName?: string[]; tenantStreet?: string[]; tenantTown?: string[]; billsNotificationEnabled?: string[]; tenantEmail?: string[]; tenantEmailStatus?: string[]; billsNotificationStrategy?: string[]; rentNotificationEnabled?: string[]; rentDueDay?: string[]; rentAmount?: string[]; updateScope?: 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(), billsNotificationEnabled: z.boolean().optional().nullable(), tenantEmail: z.string().email(t("tenant-email-invalid")).optional().or(z.literal("")).nullable(), tenantEmailStatus: z.enum([EmailStatus.Unverified, EmailStatus.VerificationPending, EmailStatus.Verified, EmailStatus.Unsubscribed]).optional().nullable(), tenantEmailLanguage: z.enum(["hr", "en"]).optional().nullable(), billsNotificationStrategy: z.enum([BillsNotificationStrategy.WhenPayed, BillsNotificationStrategy.WhenAttached]).optional().nullable(), rentNotificationEnabled: 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(["no-scope-selected", "current", "subsequent", "all"]), }) // 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.billsNotificationEnabled || data.rentNotificationEnabled) { return !!data.tenantEmail && data.tenantEmail.trim().length > 0; } return true; }, { message: t("tenant-email-required"), path: ["tenantEmail"], }) .refine((data) => { if (data.rentNotificationEnabled) { return !!data.rentAmount && data.rentAmount > 0; } return true; }, { message: t("rent-amount-required"), path: ["rentAmount"], }) .refine((data) => { // When updateScope field is present (editing mode), user must select a valid option if (data.updateScope === "no-scope-selected") { return false; } return true; }, { message: t("update-scope-required"), path: ["updateScope"], }); /** * 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, billsNotificationEnabled: formData.get('billsNotificationEnabled') === 'on', tenantEmail: formData.get('tenantEmail') || null, tenantEmailStatus: formData.get('tenantEmailStatus') as "unverified" | "verification-pending" | "verified" | "unsubscribed" | undefined, tenantEmailLanguage: formData.get('tenantEmailLanguage') as "hr" | "en" | undefined, billsNotificationStrategy: formData.get('billsNotificationStrategy') as BillsNotificationStrategy | undefined, rentNotificationEnabled: formData.get('rentNotificationEnabled') === 'on', rentDueDay: formData.get('rentDueDay') || null, rentAmount: formData.get('rentAmount') || null, addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on', updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | "", }); // 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, billsNotificationEnabled, tenantEmail, tenantEmailStatus, tenantEmailLanguage, billsNotificationStrategy, rentNotificationEnabled, 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("lokacije") .findOne({ _id: locationId, userId }, { projection: { bills: 0 } }); if (!currentLocation) { return { message: "Location not found", errors: undefined, }; } // SECURITY: Validate email status transitions // - If email changed: force to Unverified (prevents spoofing verified status) // - If email unchanged: only allow client to reset to Unverified (via reset button) // All other status transitions (Unverified→VerificationPending, VerificationPending→Verified) // must happen server-side through other mechanisms (email verification links, etc.) const emailHasChanged = currentLocation.tenantEmail !== (tenantEmail || null); const clientWantsToReset = tenantEmailStatus === EmailStatus.Unverified; const finalEmailStatus = emailHasChanged ? EmailStatus.Unverified // Email changed: force reset : clientWantsToReset ? EmailStatus.Unverified // Client initiated reset: allow it : (currentLocation.tenantEmailStatus || EmailStatus.Unverified); // Otherwise: keep current status // Handle different update scopes if (updateScope === "current" || !updateScope) { // Update only the current location (default behavior) await dbClient.collection("lokacije").updateOne( { _id: locationId, userId }, { $set: { name: locationName, tenantPaymentMethod: tenantPaymentMethod || "none", proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, billsNotificationEnabled: billsNotificationEnabled || false, tenantEmail: tenantEmail || null, tenantEmailStatus: finalEmailStatus, tenantEmailLanguage: tenantEmailLanguage || null, billsNotificationStrategy: billsNotificationStrategy || BillsNotificationStrategy.WhenPayed, rentNotificationEnabled: rentNotificationEnabled || false, rentDueDay: rentDueDay || null, rentAmount: rentAmount || null, } } ); } else if (updateScope === "subsequent") { // Update current and all subsequent months await dbClient.collection("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, billsNotificationEnabled: billsNotificationEnabled || false, tenantEmail: tenantEmail || null, tenantEmailStatus: finalEmailStatus, tenantEmailLanguage: tenantEmailLanguage || null, billsNotificationStrategy: billsNotificationStrategy || BillsNotificationStrategy.WhenPayed, rentNotificationEnabled: rentNotificationEnabled || false, rentDueDay: rentDueDay || null, rentAmount: rentAmount || null, } } ); } else if (updateScope === "all") { // Update all locations with the same name across all months await dbClient.collection("lokacije").updateMany( { userId, name: currentLocation.name }, { $set: { name: locationName, tenantPaymentMethod: tenantPaymentMethod || "none", proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, billsNotificationEnabled: billsNotificationEnabled || false, tenantEmail: tenantEmail || null, tenantEmailStatus: finalEmailStatus, tenantEmailLanguage: tenantEmailLanguage || null, billsNotificationStrategy: billsNotificationStrategy || BillsNotificationStrategy.WhenPayed, rentNotificationEnabled: rentNotificationEnabled || false, rentDueDay: rentDueDay || null, rentAmount: rentAmount || null, } } ); } } else if(yearMonth) { // Always add location to the specified month await dbClient.collection("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, billsNotificationEnabled: billsNotificationEnabled || false, tenantEmail: tenantEmail || null, tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified, tenantEmailLanguage: tenantEmailLanguage || null, billsNotificationStrategy: billsNotificationStrategy || BillsNotificationStrategy.WhenPayed, rentNotificationEnabled: rentNotificationEnabled || 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("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("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, billsNotificationEnabled: billsNotificationEnabled || false, tenantEmail: tenantEmail || null, tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified, tenantEmailLanguage: tenantEmailLanguage || null, billsNotificationStrategy: billsNotificationStrategy || BillsNotificationStrategy.WhenPayed, rentNotificationEnabled: rentNotificationEnabled || 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("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("lokacije") .aggregate([ { $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 "billsProofOfPayment.fileName": 1, "billsProofOfPayment.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("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("lokacije") .findOne( { _id: locationID }, { projection: { // don't include the attachment binary data in the response "bills.attachment.fileContentsBase64": 0, "billsProofOfPayment.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("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("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("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} * * @example * await setseenByTenantAt("507f1f77bcf86cd799439011"); */ export const setSeenByTenantAt = async (locationID: string): Promise => { const dbClient = await getDbClient(); // First check if the location exists and if seenByTenantAt is already true const location = await dbClient.collection("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("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 => { 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 uploadBillsProofOfPayment = 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("lokacije") .findOne({ _id: locationID }, { projection: { userId: 1, billsProofOfPayment: 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.billsProofOfPayment) { return { success: false, error: 'Proof of payment already uploaded for this location' }; } // 4. FILE VALIDATION const file = formData.get('billsProofOfPayment') 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("lokacije") .updateOne( { _id: locationID }, { $set: { billsProofOfPayment: attachment } } ); // 7. REVALIDATE CACHE revalidatePath(`/share/bills-due/${shareId}`, 'page'); return { success: true }; } catch (error: any) { console.error('Upload error:', error); return { success: false, error: 'Upload failed. Please try again.' }; } } /** * Upload rent proof of payment (for tenants via share link) * Similar to uploadBillsProofOfPayment but for rent payments */ export const uploadRentProofOfPayment = 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("lokacije") .findOne({ _id: locationID }, { projection: { userId: 1, rentProofOfPayment: 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.rentProofOfPayment) { return { success: false, error: 'Proof of payment already uploaded for this location' }; } // 4. FILE VALIDATION const file = formData.get('rentProofOfPayment') 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("lokacije") .updateOne( { _id: locationID }, { $set: { rentProofOfPayment: attachment } } ); // 7. REVALIDATE CACHE revalidatePath(`/share/rent-due/${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("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("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/bills-due/${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("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("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("lokacije").updateOne( { _id: locationId, shareFirstVisitedAt: null // Only update if not already set }, { $set: { shareFirstVisitedAt: new Date(), shareTTL: newTTL } } ); } return { valid: true, locationId }; }