Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emailInbox/db/emailInbox.db.server.ts
T

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",
},
});
}