feat: implement email verification and unsubscribe DB logic
- 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 <noreply@anthropic.com>
This commit is contained in:
175
web-app/app/lib/actions/emailActions.ts
Normal file
175
web-app/app/lib/actions/emailActions.ts
Normal file
@@ -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<EmailActionResult> {
|
||||||
|
// 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<BillingLocation>("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<BillingLocation>("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<EmailActionResult> {
|
||||||
|
// 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<BillingLocation>("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<BillingLocation>("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)`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export interface UserSettings {
|
|||||||
ownerRevolutProfileName?: string | null;
|
ownerRevolutProfileName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum EmailStatus {
|
export enum EmailStatus {
|
||||||
/** Email is not yet verified - recipient has not yet confirmed their email address */
|
/** Email is not yet verified - recipient has not yet confirmed their email address */
|
||||||
Unverified = "unverified",
|
Unverified = "unverified",
|
||||||
/** Email is not yet verified - a verification request has been sent */
|
/** Email is not yet verified - a verification request has been sent */
|
||||||
|
|||||||
Reference in New Issue
Block a user