diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c9c85dd..f10d589 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,8 @@ "Bash(curl:*)", "Bash(git mv:*)", "Bash(rmdir:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(git diff:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/email-server-worker/README.md b/email-server-worker/README.md new file mode 100644 index 0000000..901dea8 --- /dev/null +++ b/email-server-worker/README.md @@ -0,0 +1,27 @@ +# Email Server Worker + +This workspace contains the email server worker service for the Evidencija Režija tenant notification system. + +## Purpose + +This service manages email operations by: +- Polling MongoDB for email status changes +- Detecting unverified tenant emails (EmailStatus.Unverified) +- Sending verification emails to tenants +- Updating email status to VerificationPending +- Sending scheduled notifications (rent due, utility bills) + +## Architecture + +This is a standalone background worker service that: +- Runs independently from the Next.js web-app +- Communicates via the shared MongoDB database +- Integrates with email service provider (e.g., Mailgun, SendGrid) + +## Setup + +TBD + +## Environment Variables + +TBD diff --git a/email-server-worker/sent-mail-tester.mjs b/email-server-worker/sent-mail-tester.mjs new file mode 100644 index 0000000..873406f --- /dev/null +++ b/email-server-worker/sent-mail-tester.mjs @@ -0,0 +1,27 @@ +import FormData from "form-data"; // form-data v4.0.1 +import Mailgun from "mailgun.js"; // mailgun.js v11.1.0 + +async function sendSimpleMessage() { + const mailgun = new Mailgun(FormData); + const mg = mailgun.client({ + username: "api", + key: process.env.API_KEY || "f581edcac21ec14d086ef25e36f04432-e61ae8dd-e207f22b", + // When you have an EU-domain, you must specify the endpoint: + url: "https://api.eu.mailgun.net" + }); + try { + console.log("Sending email..."); + const data = await mg.messages.create("rezije.app", { + from: "Mailgun Sandbox ", + to: ["Nikola Derezic "], + subject: "Hello Nikola Derezic", + text: "Congratulations Nikola Derezic, you just sent an email with Mailgun! You are truly awesome!", + }); + + console.log(data); // logs response data + } catch (error) { + console.log(error); //logs any error + } +} + +sendSimpleMessage(); \ No newline at end of file diff --git a/evidencija-rezija.code-workspace b/evidencija-rezija.code-workspace index 21699eb..4a8c1c2 100644 --- a/evidencija-rezija.code-workspace +++ b/evidencija-rezija.code-workspace @@ -12,6 +12,14 @@ "name": "🔧 housekeeping", "path": "housekeeping" }, + { + "name": "📧 mailgun-webhook", + "path": "mailgun-webhook" + }, + { + "name": "⚙️ email-server-worker", + "path": "email-server-worker" + }, { "name": "📦 root", "path": "." diff --git a/mailgun-webhook/README.md b/mailgun-webhook/README.md new file mode 100644 index 0000000..0995202 --- /dev/null +++ b/mailgun-webhook/README.md @@ -0,0 +1,23 @@ +# Mailgun Webhook Handler + +This workspace contains the Mailgun webhook handler service for processing email events related to the Evidencija Režija tenant notification system. + +## Purpose + +This service handles email verification and status updates by: +- Detecting new tenant email addresses (EmailStatus.Unverified) +- Sending verification emails via Mailgun +- Updating email status to VerificationPending +- Processing webhook events from Mailgun (bounces, complaints, etc.) + +## Architecture + +This is a separate system from the Next.js web-app that communicates via the shared MongoDB database. + +## Setup + +TBD + +## Environment Variables + +TBD diff --git a/sprints/sprint--confirm-unsubscribe.md b/sprints/sprint--confirm-unsubscribe.md new file mode 100644 index 0000000..10a8b14 --- /dev/null +++ b/sprints/sprint--confirm-unsubscribe.md @@ -0,0 +1,83 @@ +# Context +App users (landlord) can assign `tenantEmail` to a `BillingLocation`. + +This is a e-mail address will be used to notify the tenant when the rent is due and/or the utility bills are due. + +## E-mail verification +To prevent missuse and ensure that the e-mail is correct, before an e-mail address can be used by the automatic notification system, the tenant needs to verifies that he/she accepts to receive notifications. + +This verification is done via a link sent to the tenant in a verification-request e-mail, which is sent to the tenant automatically when the landloard (app user) assigns this e-mail address to a BillingLocation. + +Sending of this verification-request e-mail is handled by a system separate from NextJS app in `web-app` workspace. It detects newly assigned addresses from their status bein equal `EmailStatus.Unverified`. The two systems don't talk to each other at all - what's holding them together is the DB. + +### Implementation details +Verification link points to the NextJS app in `web-app` workspace at path `/email/verify/[share-id]` (share-id is calculated using `generateShareId` from `/home/kneecola/projects/evidencija-rezija/web-app/app/lib/shareChecksum.ts`). + +The web page served at this path cerifies if the [share-id] is correct and if not it shows 404 page. + +The web page served at this path contains an text explanation and "Verify e-mail" button. + +The text includes the following information: +* what the web app is about - very short into +* why the e-mail was sent = because the landloard of the property `BillingLocation.name` configured the rent (`BillingLocation.rentDueNotification`) and/or utility bills (`BillingLocation.billFwdStrategy`) to be delivered to that e-mail address +* what will hapen if he/she clicks on the "Verify e-mail" button = they will be receiving rent due (`BillingLocation.rentDueNotification`) or utility bills due (`BillingLocation.billFwdStrategy`) notification or both - 2x a month - depending on the config set by the landloard +* opt-out infomation (they can ignore this e-mail, but can also opt-out at any moment) + +If the user clicks the button "Verify e-mail" this triggers update of `BillingLocation.tenantEmailStatus`. + +Here's the expected stats flow: + +* landloard/app user assigns an an new address to `BillingLocation` -> `BillingLocation.tenantEmailStatus` is set to `EmailStatus.Unverified` +* an automated system detects that a new address was set (as indicated by `EmailStatus.Unverified` status), it then sets verification-email -> `BillingLocation.tenantEmailStatus` is set to `EmailStatus.VerificationPending` +* tenant click the link from the verification-requets e-mail -> `BillingLocation.tenantEmailStatus` is set to `EmailStatus.Verified` + +**Note:** status is updated for the `BillingLocation` inidcated by locationID (decoded from `[share-id]` param), and all subsequent (later in time) matching `BillingLocation` records (matched by comparing `BillingLocation.name`, `BillingLocation.userId` and `BillingLocation.tenantEmail`). Similar pattern is already implemented in `updateOrAddLocation` fn in `locationAction.ts` (`updateScope === "subsequent"`). + +## E-mail unsubscribe +Tenant can out-out from receiving e-mail notifications at any time via an `unsubscribe` link included at the end of every mail sent to the tenant. + +### Implementation details +Verification link points to the NextJS app in `web-app` workspace at path `/email/unsubscribe/[share-id]` (share-id is calculated using `generateShareId` from `/home/kneecola/projects/evidencija-rezija/web-app/app/lib/shareChecksum.ts` ... search of examples of how this function is used). + +The web page served at this path contains an text explanation and "Confirm unsubscribe" button. + +The text includes the following information: +* what the web app is about - very short into +* why are they receiveing e-mails from this page = because their landlord for property `BillingLocation.name` has configured the app to deliver rent due or utility bills due notification or both to that address +* what will hapen if they click on "Confirm unsubscribe" = they will no longer receive rent due / utility bull due reminders + +E-mail address's verification status is tracked via `BillingLocation.tenantEmailStatus`, which is set to `EmailStatus.Unsubscribed`. + +**Note:** status is updated for the `BillingLocation` inidcated by locationID (decoded from `[share-id]` param), and all subsequent (later in time) matching `BillingLocation` records (matched by comparing `BillingLocation.name`, `BillingLocation.userId` and `BillingLocation.tenantEmail`). Similar pattern is already implemented in `updateOrAddLocation` fn in `locationAction.ts` (`updateScope === "subsequent"`). + +## E-mail status in `LocationCard.tsx` + +If the e-mail is not in `EmailStatus.Verified` state for a given location, then this will be indicated in `LocationCard.tsx` as a sibling of `total-payed-label` block (`
`) + +## E-mail status in `LocationEditForm.tsx` + +Current e-mail status will be indicated as a sibling of: +``` + {/* Email status indicator should go here */} +
+ ... +``` + +Use appropriate utf-8 icon for each status. + +# Logical Units of work + +Work will be split in logical units of work: + +* implement e-mail verification DB logic +* implement e-mail verification page + * create text both in croatian (hr.json) and english (en.json) + * implement share-id verification (see /home/kneecola/projects/evidencija-rezija/web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx) +* implement e-mail unsubscribe DB logic +* implement e-mail unsubscribe page + * create text both in croatian (hr.json) and english (en.json) + * implement share-id verification (see /home/kneecola/projects/evidencija-rezija/web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx) +* add email status to `LocationCard.tsx` +* add email status to `LocationEditForm.tsx` + +Each logical unit of work will be commited separatley. \ No newline at end of file diff --git a/web-app/app/[locale]/email/unsubscribe/[id]/EmailUnsubscribePage.tsx b/web-app/app/[locale]/email/unsubscribe/[id]/EmailUnsubscribePage.tsx new file mode 100644 index 0000000..a57dcf1 --- /dev/null +++ b/web-app/app/[locale]/email/unsubscribe/[id]/EmailUnsubscribePage.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { unsubscribeTenantEmail } from '@/app/lib/actions/emailActions'; +import { CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface EmailUnsubscribePageProps { + shareId: string; + isVerified: boolean; +} + +export default function EmailUnsubscribePage({ shareId, isVerified }: EmailUnsubscribePageProps) { + const t = useTranslations('email-unsubscribe-page'); + const [isUnsubscribing, setIsUnsubscribing] = useState(false); + const [isUnsubscribed, setIsUnsubscribed] = useState(false); + const [error, setError] = useState(null); + + const handleUnsubscribe = async () => { + setIsUnsubscribing(true); + setError(null); + + try { + const result = await unsubscribeTenantEmail(shareId); + + if (result.success) { + setIsUnsubscribed(true); + } else { + setError(result.message || t('error.unknown')); + } + } catch (err) { + setError(t('error.unknown')); + } finally { + setIsUnsubscribing(false); + } + }; + + if (isUnsubscribed) { + return ( +
+
+
+ +
+

+ {t('success.title')} +

+

{t('success.message')}

+
+
+ ); + } + + if (error) { + return ( +
+
+

{t('error.title')}

+

{error}

+
+
+ ); + } + + if (!isVerified) { + return ( +
+
+

{t('not-allowed.title')}

+

{t('not-allowed.message')}

+
+
+ ); + } + + return ( +
+
+

{t('title')}

+ +
+
+

{t('about.title')}

+

{t('about.description')}

+
+ +
+

{t('why.title')}

+

{t('why.description')}

+
+ +
+

{t('what-happens.title')}

+

{t('what-happens.description')}

+
+
+ +
+ +
+
+
+ ); +} diff --git a/web-app/app/[locale]/email/unsubscribe/[id]/page.tsx b/web-app/app/[locale]/email/unsubscribe/[id]/page.tsx new file mode 100644 index 0000000..5c7c5a2 --- /dev/null +++ b/web-app/app/[locale]/email/unsubscribe/[id]/page.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import EmailUnsubscribePage from './EmailUnsubscribePage'; +import { Main } from '@/app/ui/Main'; +import { getDbClient } from '@/app/lib/dbClient'; +import { BillingLocation, EmailStatus } from '@/app/lib/db-types'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; +import { notFound } from 'next/navigation'; + +export default async function Page({ params: { id } }: { params: { id: string } }) { + // Extract and validate share ID + const extracted = extractShareId(id); + if (!extracted) { + notFound(); + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + notFound(); + } + + // Fetch location to check email status + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { tenantEmail: 1, tenantEmailStatus: 1 } } + ); + + if (!location || !location.tenantEmail) { + notFound(); + } + + // Check if email is verified + const isVerified = location.tenantEmailStatus === EmailStatus.Verified; + + return ( +
+ Loading...
}> + + + + ); +} diff --git a/web-app/app/[locale]/email/verify/[id]/EmailVerifyPage.tsx b/web-app/app/[locale]/email/verify/[id]/EmailVerifyPage.tsx new file mode 100644 index 0000000..a48de4a --- /dev/null +++ b/web-app/app/[locale]/email/verify/[id]/EmailVerifyPage.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { verifyTenantEmail } from '@/app/lib/actions/emailActions'; +import { CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface EmailVerifyPageProps { + shareId: string; + isPending: boolean; +} + +export default function EmailVerifyPage({ shareId, isPending }: EmailVerifyPageProps) { + const t = useTranslations('email-verify-page'); + const [isVerifying, setIsVerifying] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [error, setError] = useState(null); + + const handleVerify = async () => { + setIsVerifying(true); + setError(null); + + try { + const result = await verifyTenantEmail(shareId); + + if (result.success) { + setIsVerified(true); + } else { + setError(result.message || t('error.unknown')); + } + } catch (err) { + setError(t('error.unknown')); + } finally { + setIsVerifying(false); + } + }; + + if (isVerified) { + return ( +
+
+
+ +
+

+ {t('success.title')} +

+

{t('success.message')}

+
+
+ ); + } + + if (error) { + return ( +
+
+

{t('error.title')}

+

{error}

+
+
+ ); + } + + if (!isPending) { + return ( +
+
+

{t('not-allowed.title')}

+

{t('not-allowed.message')}

+
+
+ ); + } + + return ( +
+
+

{t('title')}

+ +
+
+

{t('about.title')}

+

{t('about.description')}

+
+ +
+

{t('why.title')}

+

{t('why.description')}

+
+ +
+

{t('what-happens.title')}

+

{t('what-happens.description')}

+
+ +
+

{t('opt-out.title')}

+

{t('opt-out.description')}

+
+
+ +
+ +
+
+
+ ); +} diff --git a/web-app/app/[locale]/email/verify/[id]/page.tsx b/web-app/app/[locale]/email/verify/[id]/page.tsx new file mode 100644 index 0000000..3b4cc9d --- /dev/null +++ b/web-app/app/[locale]/email/verify/[id]/page.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import EmailVerifyPage from './EmailVerifyPage'; +import { Main } from '@/app/ui/Main'; +import { getDbClient } from '@/app/lib/dbClient'; +import { BillingLocation, EmailStatus } from '@/app/lib/db-types'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; +import { notFound } from 'next/navigation'; + +export default async function Page({ params: { id } }: { params: { id: string } }) { + // Extract and validate share ID + const extracted = extractShareId(id); + if (!extracted) { + notFound(); + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + notFound(); + } + + // Fetch location to check email status + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { tenantEmail: 1, tenantEmailStatus: 1 } } + ); + + if (!location || !location.tenantEmail) { + notFound(); + } + + // Check if email is pending verification + const isPending = location.tenantEmailStatus === EmailStatus.VerificationPending; + + return ( +
+ Loading...
}> + + + + ); +} diff --git a/web-app/app/lib/actions/emailActions.ts b/web-app/app/lib/actions/emailActions.ts new file mode 100644 index 0000000..a3ce652 --- /dev/null +++ b/web-app/app/lib/actions/emailActions.ts @@ -0,0 +1,175 @@ +'use server'; + +import { getDbClient } from '../dbClient'; +import { BillingLocation, EmailStatus } from '../db-types'; +import { extractShareId, validateShareChecksum } from '../shareChecksum'; +import { revalidatePath } from 'next/cache'; + +export type EmailActionResult = { + success: boolean; + message?: string; +}; + +/** + * Verify tenant email address + * Updates the email status to Verified for the location and all subsequent matching locations + * + * @param shareId - The share ID from the verification link (locationId + checksum) + * @returns Result indicating success or failure + */ +export async function verifyTenantEmail(shareId: string): Promise { + // Extract and validate share ID + const extracted = extractShareId(shareId); + if (!extracted) { + return { + success: false, + message: 'Invalid verification link' + }; + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + return { + success: false, + message: 'Invalid verification link' + }; + } + + // Get database client + const dbClient = await getDbClient(); + + // Fetch the location to get userId, name, tenantEmail, and yearMonth + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { userId: 1, name: 1, tenantEmail: 1, yearMonth: 1 } } + ); + + if (!location) { + return { + success: false, + message: 'Location not found' + }; + } + + if (!location.tenantEmail) { + return { + success: false, + message: 'No tenant email configured for this location' + }; + } + + // Update current and all subsequent matching locations + // Match by: userId, name, tenantEmail, and yearMonth >= current + const result = await dbClient.collection("lokacije").updateMany( + { + userId: location.userId, + name: location.name, + tenantEmail: location.tenantEmail, + $or: [ + { "yearMonth.year": { $gt: location.yearMonth.year } }, + { + "yearMonth.year": location.yearMonth.year, + "yearMonth.month": { $gte: location.yearMonth.month } + } + ] + }, + { + $set: { + tenantEmailStatus: EmailStatus.Verified + } + } + ); + + // Revalidate paths to refresh UI + revalidatePath('/[locale]', 'layout'); + + return { + success: true, + message: `Email verified successfully (${result.modifiedCount} location(s) updated)` + }; +} + +/** + * Unsubscribe tenant from email notifications + * Updates the email status to Unsubscribed for the location and all subsequent matching locations + * + * @param shareId - The share ID from the unsubscribe link (locationId + checksum) + * @returns Result indicating success or failure + */ +export async function unsubscribeTenantEmail(shareId: string): Promise { + // Extract and validate share ID + const extracted = extractShareId(shareId); + if (!extracted) { + return { + success: false, + message: 'Invalid unsubscribe link' + }; + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + return { + success: false, + message: 'Invalid unsubscribe link' + }; + } + + // Get database client + const dbClient = await getDbClient(); + + // Fetch the location to get userId, name, tenantEmail, and yearMonth + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { userId: 1, name: 1, tenantEmail: 1, yearMonth: 1 } } + ); + + if (!location) { + return { + success: false, + message: 'Location not found' + }; + } + + if (!location.tenantEmail) { + return { + success: false, + message: 'No tenant email configured for this location' + }; + } + + // Update current and all subsequent matching locations + // Match by: userId, name, tenantEmail, and yearMonth >= current + const result = await dbClient.collection("lokacije").updateMany( + { + userId: location.userId, + name: location.name, + tenantEmail: location.tenantEmail, + $or: [ + { "yearMonth.year": { $gt: location.yearMonth.year } }, + { + "yearMonth.year": location.yearMonth.year, + "yearMonth.month": { $gte: location.yearMonth.month } + } + ] + }, + { + $set: { + tenantEmailStatus: EmailStatus.Unsubscribed + } + } + ); + + // Revalidate paths to refresh UI + revalidatePath('/[locale]', 'layout'); + + return { + success: true, + message: `Unsubscribed successfully (${result.modifiedCount} location(s) updated)` + }; +} diff --git a/web-app/app/lib/actions/locationActions.ts b/web-app/app/lib/actions/locationActions.ts index 489b787..d548d2f 100644 --- a/web-app/app/lib/actions/locationActions.ts +++ b/web-app/app/lib/actions/locationActions.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { getDbClient } from '../dbClient'; -import { BillingLocation, FileAttachment, YearMonth } from '../db-types'; +import { BillingLocation, FileAttachment, YearMonth, EmailStatus } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; @@ -22,6 +22,7 @@ export type State = { tenantTown?: string[]; autoBillFwd?: string[]; tenantEmail?: string[]; + tenantEmailStatus?: string[]; billFwdStrategy?: string[]; rentDueNotification?: string[]; rentDueDay?: string[]; @@ -44,6 +45,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ 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(), + tenantEmailStatus: z.enum([EmailStatus.Unverified, EmailStatus.VerificationPending, EmailStatus.Verified, EmailStatus.Unsubscribed]).optional().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(), @@ -122,6 +124,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: formData.get('tenantTown') || null, autoBillFwd: formData.get('autoBillFwd') === 'on', tenantEmail: formData.get('tenantEmail') || null, + tenantEmailStatus: formData.get('tenantEmailStatus') as "unverified" | "verification-pending" | "verified" | "unsubscribed" | undefined, billFwdStrategy: formData.get('billFwdStrategy') as "when-payed" | "when-attached" | undefined, rentDueNotification: formData.get('rentDueNotification') === 'on', rentDueDay: formData.get('rentDueDay') || null, @@ -147,6 +150,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown, autoBillFwd, tenantEmail, + tenantEmailStatus, billFwdStrategy, rentDueNotification, rentDueDay, @@ -172,6 +176,20 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat }; } + // 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) @@ -190,6 +208,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: finalEmailStatus, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -221,6 +240,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: finalEmailStatus, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -245,6 +265,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: finalEmailStatus, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -268,6 +289,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -343,6 +365,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -446,6 +469,8 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu "bills.payedAmount": 1, "bills.proofOfPayment.uploadedAt": 1, "seenByTenantAt": 1, + "tenantEmail": 1, + "tenantEmailStatus": 1, // "bills.attachment": 0, // "bills.notes": 0, // "bills.hub3aText": 1, diff --git a/web-app/app/lib/db-types.ts b/web-app/app/lib/db-types.ts index 87a93ef..a21b62e 100644 --- a/web-app/app/lib/db-types.ts +++ b/web-app/app/lib/db-types.ts @@ -1,3 +1,4 @@ +import { unsubscribe } from "diagnostics_channel"; export interface FileAttachment { fileName: string; @@ -35,6 +36,17 @@ export interface UserSettings { ownerRevolutProfileName?: string | null; }; +export enum EmailStatus { + /** Email is not yet verified - recipient has not yet confirmed their email address */ + Unverified = "unverified", + /** Email is not yet verified - a verification request has been sent */ + VerificationPending = "verification-pending", + /** Email is verified and is in good standing: emails are being successfully delivered */ + Verified = "verified", + /** Recepient has unsubscribed from receiving emails via link - no further emails will be sent */ + Unsubscribed = "unsubscribed" +} + /** bill object in the form returned by MongoDB */ export interface BillingLocation { _id: string; @@ -67,6 +79,8 @@ export interface BillingLocation { autoBillFwd?: boolean | null; /** (optional) tenant email */ tenantEmail?: string | null; + /** (optional) tenant email status */ + tenantEmailStatus?: EmailStatus | null; /** (optional) bill forwarding strategy */ billFwdStrategy?: "when-payed" | "when-attached" | null; /** (optional) whether to automatically send rent notification */ diff --git a/web-app/app/ui/EnterOrSignInButton.tsx b/web-app/app/ui/EnterOrSignInButton.tsx index feef0cd..ba2083f 100644 --- a/web-app/app/ui/EnterOrSignInButton.tsx +++ b/web-app/app/ui/EnterOrSignInButton.tsx @@ -15,7 +15,7 @@ export const EnterOrSignInButton: FC<{ session: any, locale: string, providers: return ( <> { - !session ? ( + session ? ( -
{children}
+
{children}
) } diff --git a/web-app/app/ui/LocationCard.tsx b/web-app/app/ui/LocationCard.tsx index f175f20..d7a34bb 100644 --- a/web-app/app/ui/LocationCard.tsx +++ b/web-app/app/ui/LocationCard.tsx @@ -1,9 +1,9 @@ 'use client'; -import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon, EnvelopeIcon, ExclamationTriangleIcon, ClockIcon } from "@heroicons/react/24/outline"; import { FC } from "react"; import { BillBadge } from "./BillBadge"; -import { BillingLocation } from "../lib/db-types"; +import { BillingLocation, EmailStatus } from "../lib/db-types"; import { formatYearMonth } from "../lib/format"; import { formatCurrency } from "../lib/formatStrings"; import Link from "next/link"; @@ -25,10 +25,11 @@ export const LocationCard: FC = ({ location, currency }) => { seenByTenantAt, // NOTE: only the fileName is projected from the DB to reduce data transfer utilBillsProofOfPayment, + tenantEmail, + tenantEmailStatus, } = location; const t = useTranslations("home-page.location-card"); - const currentLocale = useLocale(); // sum all the unpaid and paid bill amounts (regardless of who pays) const totalUnpaid = bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); @@ -69,7 +70,7 @@ export const LocationCard: FC = ({ location, currency }) => { - { totalUnpaid > 0 || totalPayed > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ? + { totalUnpaid > 0 || totalPayed > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt || (tenantEmail && tenantEmailStatus && tenantEmailStatus !== EmailStatus.Verified) ? <>
@@ -95,6 +96,24 @@ export const LocationCard: FC = ({ location, currency }) => {
: null } + {tenantEmail && tenantEmailStatus && tenantEmailStatus !== EmailStatus.Verified && ( +
+ + {tenantEmailStatus === EmailStatus.Unverified && } + {tenantEmailStatus === EmailStatus.VerificationPending && } + {tenantEmailStatus === EmailStatus.Unsubscribed && } + + + {tenantEmailStatus === EmailStatus.Unverified && `${t("email-status.unverified")}`} + {tenantEmailStatus === EmailStatus.VerificationPending && `${t("email-status.verification-pending")}`} + {tenantEmailStatus === EmailStatus.Unsubscribed && `${t("email-status.unsubscribed")}`} + +
+ )} {seenByTenantAt && (
diff --git a/web-app/app/ui/LocationEditForm.tsx b/web-app/app/ui/LocationEditForm.tsx index ae05270..3b392d3 100644 --- a/web-app/app/ui/LocationEditForm.tsx +++ b/web-app/app/ui/LocationEditForm.tsx @@ -1,8 +1,8 @@ "use client"; -import { TrashIcon } from "@heroicons/react/24/outline"; +import { TrashIcon, ExclamationTriangleIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon, PencilSquareIcon } from "@heroicons/react/24/outline"; import { FC, useState } from "react"; -import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types"; +import { BillingLocation, UserSettings, YearMonth, EmailStatus } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; import Link from "next/link"; @@ -41,6 +41,7 @@ export const LocationEditForm: FC = ({ location, yearMont tenantStreet: location?.tenantStreet ?? "", tenantTown: location?.tenantTown ?? "", tenantEmail: location?.tenantEmail ?? "", + tenantEmailStatus: location?.tenantEmailStatus ?? EmailStatus.Unverified, tenantPaymentMethod: location?.tenantPaymentMethod ?? "none", proofOfPaymentType: location?.proofOfPaymentType ?? "none", autoBillFwd: location?.autoBillFwd ?? false, @@ -50,10 +51,21 @@ export const LocationEditForm: FC = ({ location, yearMont rentDueDay: location?.rentDueDay ?? 1, }); + // tenant e-mail fetched from database + const [dbTenantEmail, setDbTenantEmail] = useState(location?.tenantEmail ?? ""); + const handleInputChange = (field: keyof typeof formValues, value: string | boolean | number) => { setFormValues(prev => ({ ...prev, [field]: value })); }; + const handleResetEmailStatus = () => { + // this will simulate that the email + // is new and needs verification + setDbTenantEmail(""); + // reset the email status to unverified + setFormValues(prev => ({ ...prev, tenantEmailStatus: EmailStatus.Unverified })); + }; + let { year, month } = location ? location.yearMonth : yearMonth; return ( @@ -265,7 +277,6 @@ export const LocationEditForm: FC = ({ location, yearMont