393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
// VA-P23 · email-inbox DB access (thin Prisma wrappers). Reads are tenant-scoped
|
|
// and — for read_own callers — owner-scoped (M09 Block I). Token ciphertext
|
|
// columns are NEVER selected into these read shapes; only the connect/refresh
|
|
// services touch them. Provider webhooks bootstrap only through narrow database
|
|
// resolvers; the lookups at the bottom are exact re-reads inside that RLS context.
|
|
|
|
import { db } from "~/utils/db.server";
|
|
|
|
/** Mailbox fields safe to surface (no token ciphertext, no clientState). */
|
|
export type EmailMailboxListRow = {
|
|
id: string;
|
|
ownerUserId: string;
|
|
provider: "gmail" | "microsoft";
|
|
emailAddress: string;
|
|
displayName: string | null;
|
|
isDefaultForUser: boolean;
|
|
status: "connecting" | "active" | "paused" | "error" | "revoked";
|
|
lastSyncAt: Date | null;
|
|
lastErrorText: string | null;
|
|
webhookSubscriptionExpiresAt: Date | null;
|
|
};
|
|
|
|
const MAILBOX_SAFE_SELECT = {
|
|
id: true,
|
|
ownerUserId: true,
|
|
provider: true,
|
|
emailAddress: true,
|
|
displayName: true,
|
|
isDefaultForUser: true,
|
|
status: true,
|
|
lastSyncAt: true,
|
|
lastErrorText: true,
|
|
webhookSubscriptionExpiresAt: true,
|
|
} as const;
|
|
|
|
/** The current user's own mailboxes (newest first). */
|
|
export async function listMailboxesForUser(tenantId: string, ownerUserId: string): Promise<EmailMailboxListRow[]> {
|
|
return db.emailIntegrationMailbox.findMany({
|
|
where: { tenantId, ownerUserId, archivedAt: null },
|
|
select: MAILBOX_SAFE_SELECT,
|
|
orderBy: { createdAt: "desc" },
|
|
}) as unknown as Promise<EmailMailboxListRow[]>;
|
|
}
|
|
|
|
/** Every tenant mailbox (read_all surface). */
|
|
export async function listMailboxesForTenant(tenantId: string): Promise<EmailMailboxListRow[]> {
|
|
return db.emailIntegrationMailbox.findMany({
|
|
where: { tenantId, archivedAt: null },
|
|
select: MAILBOX_SAFE_SELECT,
|
|
orderBy: { createdAt: "desc" },
|
|
}) as unknown as Promise<EmailMailboxListRow[]>;
|
|
}
|
|
|
|
/** One mailbox by id (tenant-scoped). Returns ownerUserId so the route owner-checks. */
|
|
export async function getMailboxForTenant(tenantId: string, mailboxId: string): Promise<EmailMailboxListRow | null> {
|
|
return db.emailIntegrationMailbox.findFirst({
|
|
where: { id: mailboxId, tenantId, archivedAt: null },
|
|
select: MAILBOX_SAFE_SELECT,
|
|
}) as unknown as Promise<EmailMailboxListRow | null>;
|
|
}
|
|
|
|
/** Initial-sync progress rows for the settings screen (per folder). */
|
|
export type EmailSyncCursorRow = {
|
|
mailboxId: string;
|
|
externalFolderId: string;
|
|
initialSyncStatus: "pending" | "running" | "completed" | "failed";
|
|
initialSyncProgress: unknown;
|
|
lastCursorAdvanceAt: Date | null;
|
|
errorText: string | null;
|
|
};
|
|
|
|
export async function listSyncCursorsForMailboxes(tenantId: string, mailboxIds: readonly string[]): Promise<EmailSyncCursorRow[]> {
|
|
if (mailboxIds.length === 0) {
|
|
return [];
|
|
}
|
|
return db.emailIntegrationSyncCursor.findMany({
|
|
where: { tenantId, mailboxId: { in: [...mailboxIds] } },
|
|
select: {
|
|
mailboxId: true,
|
|
externalFolderId: true,
|
|
initialSyncStatus: true,
|
|
initialSyncProgress: true,
|
|
lastCursorAdvanceAt: true,
|
|
errorText: true,
|
|
},
|
|
}) as unknown as Promise<EmailSyncCursorRow[]>;
|
|
}
|
|
|
|
/** List-row projection of a mirrored message (NO bodies — snippet only). */
|
|
export type EmailMessageListRow = {
|
|
id: string;
|
|
mailboxId: string;
|
|
direction: "inbound" | "outbound";
|
|
fromAddress: string;
|
|
fromName: string | null;
|
|
subject: string | null;
|
|
snippet: string | null;
|
|
sentAt: Date;
|
|
isRead: boolean;
|
|
hasAttachments: boolean;
|
|
isNewsletter: boolean;
|
|
matchedKontaktId: string | null;
|
|
matchedLeadId: string | null;
|
|
matchedProjektId: string | null;
|
|
};
|
|
|
|
export interface ListMessagesFilter {
|
|
mailboxIds: readonly string[];
|
|
onlyMailboxId?: string | null;
|
|
direction?: "inbound" | "outbound" | null;
|
|
linked?: "linked" | "unlinked" | null;
|
|
read?: "read" | "unread" | null;
|
|
q?: string | null;
|
|
showArchived?: boolean;
|
|
/** Keyset cursor: rows strictly older than (sentAt, id). */
|
|
cursor?: { sentAt: Date; id: string } | null;
|
|
take: number;
|
|
}
|
|
|
|
/**
|
|
* Server-side inbox list (M09 Block F): filters + trigram-ish search (ILIKE on
|
|
* subject/from/bodyText — v1) + keyset pagination on (sentAt DESC, id DESC).
|
|
* `mailboxIds` is the caller's VISIBILITY set (read_own → own mailboxes only).
|
|
*/
|
|
export async function listMessagesForMailboxes(tenantId: string, filter: ListMessagesFilter): Promise<EmailMessageListRow[]> {
|
|
if (filter.mailboxIds.length === 0) {
|
|
return [];
|
|
}
|
|
const mailboxScope = filter.onlyMailboxId && filter.mailboxIds.includes(filter.onlyMailboxId) ? [filter.onlyMailboxId] : [...filter.mailboxIds];
|
|
const where: Record<string, unknown> = {
|
|
tenantId,
|
|
mailboxId: { in: mailboxScope },
|
|
...(filter.showArchived ? {} : { archivedAt: null }),
|
|
...(filter.direction ? { direction: filter.direction } : {}),
|
|
...(filter.read ? { isRead: filter.read === "read" } : {}),
|
|
};
|
|
if (filter.linked === "linked") {
|
|
where.OR = [{ matchedKontaktId: { not: null } }, { matchedLeadId: { not: null } }, { matchedProjektId: { not: null } }];
|
|
} else if (filter.linked === "unlinked") {
|
|
where.AND = [{ matchedKontaktId: null }, { matchedLeadId: null }, { matchedProjektId: null }];
|
|
}
|
|
if (filter.q) {
|
|
const q = filter.q;
|
|
where.OR = [
|
|
...((where.OR as unknown[] | undefined) ?? []),
|
|
{ subject: { contains: q, mode: "insensitive" } },
|
|
{ fromAddress: { contains: q, mode: "insensitive" } },
|
|
{ fromName: { contains: q, mode: "insensitive" } },
|
|
{ bodyText: { contains: q, mode: "insensitive" } },
|
|
];
|
|
}
|
|
if (filter.cursor) {
|
|
where.AND = [
|
|
...((where.AND as unknown[] | undefined) ?? []),
|
|
{
|
|
OR: [{ sentAt: { lt: filter.cursor.sentAt } }, { sentAt: filter.cursor.sentAt, id: { lt: filter.cursor.id } }],
|
|
},
|
|
];
|
|
}
|
|
return db.emailIntegrationMessage.findMany({
|
|
where: where as never,
|
|
select: {
|
|
id: true,
|
|
mailboxId: true,
|
|
direction: true,
|
|
fromAddress: true,
|
|
fromName: true,
|
|
subject: true,
|
|
snippet: true,
|
|
sentAt: true,
|
|
isRead: true,
|
|
hasAttachments: true,
|
|
isNewsletter: true,
|
|
matchedKontaktId: true,
|
|
matchedLeadId: true,
|
|
matchedProjektId: true,
|
|
},
|
|
orderBy: [{ sentAt: "desc" }, { id: "desc" }],
|
|
take: filter.take,
|
|
}) as unknown as Promise<EmailMessageListRow[]>;
|
|
}
|
|
|
|
/** Full detail projection of one mirrored message (bodies included). */
|
|
export type EmailMessageDetailRow = EmailMessageListRow & {
|
|
providerMessageId: string;
|
|
providerThreadId: string | null;
|
|
internetMessageId: string | null;
|
|
toAddresses: unknown;
|
|
ccAddresses: unknown;
|
|
bodyHtml: string | null;
|
|
bodyText: string | null;
|
|
receivedAt: Date | null;
|
|
matchConfidence: "high" | "medium" | "low" | "manual" | "none";
|
|
manualOverrideAt: Date | null;
|
|
manualOverrideUserId: string | null;
|
|
archivedAt: Date | null;
|
|
};
|
|
|
|
const MESSAGE_DETAIL_SELECT = {
|
|
id: true,
|
|
mailboxId: true,
|
|
providerMessageId: true,
|
|
providerThreadId: true,
|
|
internetMessageId: true,
|
|
direction: true,
|
|
fromAddress: true,
|
|
fromName: true,
|
|
toAddresses: true,
|
|
ccAddresses: true,
|
|
subject: true,
|
|
snippet: true,
|
|
bodyHtml: true,
|
|
bodyText: true,
|
|
sentAt: true,
|
|
receivedAt: true,
|
|
isRead: true,
|
|
hasAttachments: true,
|
|
isNewsletter: true,
|
|
matchedKontaktId: true,
|
|
matchedLeadId: true,
|
|
matchedProjektId: true,
|
|
matchConfidence: true,
|
|
manualOverrideAt: true,
|
|
manualOverrideUserId: true,
|
|
archivedAt: true,
|
|
} as const;
|
|
|
|
/** One message (tenant + visibility scoped by the caller's mailbox set). */
|
|
export async function getMessageForMailboxes(tenantId: string, mailboxIds: readonly string[], messageId: string): Promise<EmailMessageDetailRow | null> {
|
|
if (mailboxIds.length === 0) {
|
|
return null;
|
|
}
|
|
return db.emailIntegrationMessage.findFirst({
|
|
where: { id: messageId, tenantId, mailboxId: { in: [...mailboxIds] } },
|
|
select: MESSAGE_DETAIL_SELECT,
|
|
}) as unknown as Promise<EmailMessageDetailRow | null>;
|
|
}
|
|
|
|
/** All messages of the thread, chronological (M09 Block F detail). */
|
|
export async function listThreadMessages(
|
|
tenantId: string,
|
|
mailboxIds: readonly string[],
|
|
providerThreadId: string,
|
|
): Promise<EmailMessageDetailRow[]> {
|
|
if (mailboxIds.length === 0) {
|
|
return [];
|
|
}
|
|
return db.emailIntegrationMessage.findMany({
|
|
where: { tenantId, mailboxId: { in: [...mailboxIds] }, providerThreadId },
|
|
select: MESSAGE_DETAIL_SELECT,
|
|
orderBy: [{ sentAt: "asc" }, { id: "asc" }],
|
|
}) as unknown as Promise<EmailMessageDetailRow[]>;
|
|
}
|
|
|
|
/**
|
|
* Manual link override (M09 Block E): the user's assignment wins and blocks any
|
|
* re-match. Passing all-null targets CLEARS the link (still an override).
|
|
*/
|
|
export async function setMessageMatchOverride(
|
|
tenantId: string,
|
|
mailboxIds: readonly string[],
|
|
messageId: string,
|
|
byUserId: string,
|
|
targets: { kontaktId: string | null; leadId: string | null; projektId: string | null },
|
|
): Promise<{ count: number }> {
|
|
return db.emailIntegrationMessage.updateMany({
|
|
where: { id: messageId, tenantId, mailboxId: { in: [...mailboxIds] } },
|
|
data: {
|
|
matchedKontaktId: targets.kontaktId,
|
|
matchedLeadId: targets.leadId,
|
|
matchedProjektId: targets.projektId,
|
|
matchConfidence: "manual",
|
|
manualOverrideAt: new Date(),
|
|
manualOverrideUserId: byUserId,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Files already imported for a message (FileEntityLink entityType email_message). */
|
|
export async function listImportedAttachmentFiles(
|
|
tenantId: string,
|
|
messageId: string,
|
|
): Promise<Array<{ fileId: string; displayName: string; sizeBytes: number; mimeType: string }>> {
|
|
const links = await db.fileEntityLink.findMany({
|
|
where: { tenantId, entityType: "email_message", entityId: messageId },
|
|
select: { fileId: true },
|
|
});
|
|
if (links.length === 0) {
|
|
return [];
|
|
}
|
|
const files = await db.file.findMany({
|
|
where: { tenantId, id: { in: links.map((l) => l.fileId) }, archivedAt: null },
|
|
select: { id: true, displayName: true, sizeBytes: true, mimeType: true },
|
|
});
|
|
return files.map((f) => ({ fileId: f.id, displayName: f.displayName, sizeBytes: Number(f.sizeBytes), mimeType: f.mimeType }));
|
|
}
|
|
|
|
/** Bulk read/unread + HiveGrid-side archive (provider untouched by archive). */
|
|
export async function bulkUpdateMessages(
|
|
tenantId: string,
|
|
mailboxIds: readonly string[],
|
|
messageIds: readonly string[],
|
|
patch: { isRead?: boolean; archived?: boolean },
|
|
): Promise<{ count: number }> {
|
|
if (messageIds.length === 0 || mailboxIds.length === 0) {
|
|
return { count: 0 };
|
|
}
|
|
return db.emailIntegrationMessage.updateMany({
|
|
where: { tenantId, mailboxId: { in: [...mailboxIds] }, id: { in: [...messageIds] } },
|
|
data: {
|
|
...(patch.isRead !== undefined ? { isRead: patch.isRead } : {}),
|
|
...(patch.archived !== undefined ? { archivedAt: patch.archived ? new Date() : null } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Provider ids of messages (for the read-state write-back to the provider). */
|
|
export async function getProviderMessageRefs(
|
|
tenantId: string,
|
|
mailboxIds: readonly string[],
|
|
messageIds: readonly string[],
|
|
): Promise<Array<{ id: string; mailboxId: string; providerMessageId: string }>> {
|
|
if (messageIds.length === 0) {
|
|
return [];
|
|
}
|
|
return db.emailIntegrationMessage.findMany({
|
|
where: { tenantId, mailboxId: { in: [...mailboxIds] }, id: { in: [...messageIds] } },
|
|
select: { id: true, mailboxId: true, providerMessageId: true },
|
|
});
|
|
}
|
|
|
|
/** Exact Gmail full-row re-read after a narrow bootstrap identity is selected. */
|
|
export async function getGmailWebhookMailbox(input: {
|
|
tenantId: string;
|
|
ownerUserId: string;
|
|
mailboxId: string;
|
|
emailAddress: string;
|
|
}): Promise<{ id: string; tenantId: string; ownerUserId: string } | null> {
|
|
return db.emailIntegrationMailbox.findFirst({
|
|
where: {
|
|
id: input.mailboxId,
|
|
tenantId: input.tenantId,
|
|
ownerUserId: input.ownerUserId,
|
|
provider: "gmail",
|
|
emailAddress: { equals: input.emailAddress, mode: "insensitive" },
|
|
status: "active",
|
|
archivedAt: null,
|
|
},
|
|
select: { id: true, tenantId: true, ownerUserId: true },
|
|
});
|
|
}
|
|
|
|
/** Exact Microsoft full-row re-read with both provider-issued secrets. */
|
|
export async function getMicrosoftWebhookMailbox(input: {
|
|
tenantId: string;
|
|
ownerUserId: string;
|
|
mailboxId: string;
|
|
subscriptionId: string;
|
|
clientState: string;
|
|
}): Promise<{ id: string; tenantId: string; ownerUserId: string } | null> {
|
|
return db.emailIntegrationMailbox.findFirst({
|
|
where: {
|
|
id: input.mailboxId,
|
|
tenantId: input.tenantId,
|
|
ownerUserId: input.ownerUserId,
|
|
provider: "microsoft",
|
|
webhookSubscriptionId: input.subscriptionId,
|
|
webhookClientState: input.clientState,
|
|
status: "active",
|
|
archivedAt: null,
|
|
},
|
|
select: { id: true, tenantId: true, ownerUserId: true },
|
|
});
|
|
}
|
|
|
|
/** Persist an inbound provider push as pending (idempotency + retry). */
|
|
export async function recordEmailWebhookEvent(input: {
|
|
tenantId: string;
|
|
mailboxId: string;
|
|
provider: "gmail" | "microsoft";
|
|
notificationPayload: Record<string, unknown>;
|
|
}): Promise<void> {
|
|
await db.emailIntegrationWebhookEvent.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
mailboxId: input.mailboxId,
|
|
provider: input.provider,
|
|
notificationPayload: input.notificationPayload as never,
|
|
processingStatus: "pending",
|
|
},
|
|
});
|
|
}
|