142 lines
4.1 KiB
TypeScript
142 lines
4.1 KiB
TypeScript
// services/tenantInviteService.ts
|
|
// Handle tenant invite creation and updates
|
|
|
|
import { and, eq, ilike } from "drizzle-orm";
|
|
import { db } from "../db/db.ts";
|
|
import { tenant_invites } from "../db/schema.ts";
|
|
import type { TenantInvite } from "../db/interfaces.ts";
|
|
|
|
function parseInviteEmails(rawEmails?: string | null) {
|
|
if (!rawEmails) return [];
|
|
try {
|
|
const parsed = JSON.parse(rawEmails);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((entry) => typeof entry === "string");
|
|
} catch {
|
|
return rawEmails.split(/[\n,]+/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
}
|
|
|
|
function normalizeInviteEmail(email: string) {
|
|
return email.trim().toLowerCase();
|
|
}
|
|
|
|
function hasMatchingInviteEmail(
|
|
invite: TenantInvite,
|
|
normalizedEmail: string,
|
|
) {
|
|
const emails = parseInviteEmails(invite.invited_emails);
|
|
return emails.some((email) => email.toLowerCase() === normalizedEmail);
|
|
}
|
|
|
|
export async function createTenantInvite(
|
|
landlordId: string,
|
|
propertyId: string,
|
|
options: {
|
|
availableOn?: number | null;
|
|
invitedEmails?: string[];
|
|
} = {},
|
|
) {
|
|
const existing = await db.select().from(tenant_invites).where(
|
|
and(
|
|
eq(tenant_invites.landlord_id, landlordId),
|
|
eq(tenant_invites.property_id, propertyId),
|
|
),
|
|
);
|
|
if (existing.length > 0) {
|
|
return existing;
|
|
}
|
|
const now = Date.now();
|
|
const invitedEmails = options.invitedEmails ?? [];
|
|
return await db.insert(tenant_invites).values({
|
|
id: crypto.randomUUID(),
|
|
landlord_id: landlordId,
|
|
property_id: propertyId,
|
|
available_on: options.availableOn ?? null,
|
|
invited_emails: JSON.stringify(
|
|
invitedEmails.map((e) => String(e).trim()).filter(Boolean),
|
|
),
|
|
created_on: now,
|
|
updated_on: now,
|
|
}).returning();
|
|
}
|
|
|
|
export async function getTenantInviteById(
|
|
inviteId: string,
|
|
): Promise<TenantInvite | null> {
|
|
const result = await db.select().from(tenant_invites).where(
|
|
eq(tenant_invites.id, inviteId),
|
|
);
|
|
return result[0] || null;
|
|
}
|
|
|
|
export async function getTenantInviteByProperty(
|
|
landlordId: string,
|
|
propertyId: string,
|
|
): Promise<TenantInvite | null> {
|
|
const result = await db.select().from(tenant_invites).where(
|
|
and(
|
|
eq(tenant_invites.property_id, propertyId),
|
|
eq(tenant_invites.landlord_id, landlordId),
|
|
),
|
|
);
|
|
return result[0] || null;
|
|
}
|
|
|
|
export async function updateTenantInvite(
|
|
inviteId: string,
|
|
updates: Partial<TenantInvite>,
|
|
) {
|
|
const now = Date.now();
|
|
return await db.update(tenant_invites)
|
|
.set({ ...updates, updated_on: now })
|
|
.where(eq(tenant_invites.id, inviteId))
|
|
.returning();
|
|
}
|
|
|
|
/** Extract plain email from "Display Name <email@example.com>" or return trimmed input. */
|
|
function toPlainEmail(email: string): string {
|
|
const trimmed = email.trim();
|
|
const match = trimmed.match(/<([^>]+)>/);
|
|
return (match ? match[1].trim() : trimmed).toLowerCase();
|
|
}
|
|
|
|
export async function getTenantInvitesByEmail(email: string) {
|
|
const normalizedEmail = toPlainEmail(email);
|
|
|
|
if (!normalizedEmail) {
|
|
return [];
|
|
}
|
|
// ILIKE substring match then exact filter (handles JSON array and comma-separated stored format).
|
|
const pattern = `%${normalizedEmail}%`;
|
|
const result = await db.select().from(tenant_invites).where(
|
|
ilike(tenant_invites.invited_emails, pattern),
|
|
);
|
|
return result.filter((invite) => {
|
|
return hasMatchingInviteEmail(invite, normalizedEmail);
|
|
});
|
|
}
|
|
|
|
export async function removeTenantInviteEmail(inviteId: string, email: string) {
|
|
const normalizedEmail = normalizeInviteEmail(email);
|
|
if (!normalizedEmail) {
|
|
return { removed: false, invite: null };
|
|
}
|
|
const invite = await getTenantInviteById(inviteId);
|
|
if (!invite) return { removed: false, invite: null };
|
|
|
|
const existingEmails = parseInviteEmails(invite.invited_emails);
|
|
const filteredEmails = existingEmails.filter((entry) =>
|
|
entry.toLowerCase() !== normalizedEmail
|
|
);
|
|
if (filteredEmails.length === existingEmails.length) {
|
|
return { removed: false, invite };
|
|
}
|
|
const updated = await updateTenantInvite(inviteId, {
|
|
invited_emails: JSON.stringify(filteredEmails),
|
|
});
|
|
return { removed: true, invite: updated[0] ?? invite };
|
|
}
|