From c1d3026f4bffe9612e4994fc88991cd546520534 Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Mon, 29 Dec 2025 18:13:41 +0100 Subject: [PATCH] feat: implement email verification and unsubscribe DB logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export EmailStatus enum from db-types.ts - Add verifyTenantEmail server action - Add unsubscribeTenantEmail server action - Both actions update current and all subsequent matching locations - Match criteria: userId, name, tenantEmail, yearMonth >= current - Share-id validation using existing shareChecksum utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- web-app/app/lib/actions/emailActions.ts | 175 ++++++++++++++++++++++++ web-app/app/lib/db-types.ts | 2 +- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 web-app/app/lib/actions/emailActions.ts 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/db-types.ts b/web-app/app/lib/db-types.ts index a24c9c1..a21b62e 100644 --- a/web-app/app/lib/db-types.ts +++ b/web-app/app/lib/db-types.ts @@ -36,7 +36,7 @@ export interface UserSettings { ownerRevolutProfileName?: string | null; }; -enum EmailStatus { +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 */