Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/crm/services/CrmService.ts
T

551 lines
16 KiB
TypeScript

import { loadWebConfig } from "@toolctrl/config";
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
import { Colors } from "~/application/enums/shared/Colors";
import { RowRelationshipsApi } from "~/utils/api/.server/RowRelationshipsApi";
import { RowsApi } from "~/utils/api/.server/RowsApi";
import { dbRead } from "~/utils/db.server";
import { EntityWithDetails, findEntityByName, getEntitiesByName, getEntityByName } from "~/utils/db/entities/entities.db.server";
import { RowWithDetails, countRows, getRowById, getRows, getRowsInIds } from "~/utils/db/entities/rows.db.server";
import RowValueService, { RowValueUpdateDto } from "~/utils/helpers/.server/RowValueService";
import RowHelper from "~/utils/helpers/RowHelper";
import RowValueHelper from "~/utils/helpers/RowValueHelper";
import { t as i18nT } from "i18next";
import { createScopedLogger } from "~/utils/logger.server";
import { ensureProductCoreCrmEntities } from "./ProductCoreCrmEntities.server";
// EU-Sterilisation (R-P1a): ConvertKit (US-Newsletter-Provider) entfernt. Das CRM bleibt
// (Contacts/Companies/Opportunities/Submissions); Newsletter laufen ausschließlich über das
// eigene CRM. ConvertKit-Subscriber-Sync, -Fetch und -Config sind gestrichen.
const crmLogger = createScopedLogger("crm");
export class CrmConfigurationError extends Error {
readonly code = "CRM_CONFIGURATION_INVALID";
constructor() {
super("CRM_CONFIGURATION_INVALID");
this.name = "CrmConfigurationError";
}
}
export class CrmProviderError extends Error {
readonly code = "CRM_PROVIDER_UNAVAILABLE";
constructor() {
super("CRM_PROVIDER_UNAVAILABLE");
this.name = "CrmProviderError";
}
}
async function validate(tenantId: string | null = null) {
if (tenantId !== null) {
await ensureProductCoreCrmEntities();
}
const crmEntities = ["company", "opportunity", "contact"];
if (tenantId === null) {
crmEntities.push("submission");
}
const entities = await getEntitiesByName(crmEntities);
await Promise.all(
crmEntities.map(async (crmEntity) => {
const entity = entities.find((i) => i.name === crmEntity);
if (!entity) {
throw new CrmConfigurationError();
}
if (tenantId === null && ![DefaultEntityTypes.All, DefaultEntityTypes.AdminOnly].find((f) => f.toString() === entity.type)) {
throw new CrmConfigurationError();
} else if (tenantId !== null && ![DefaultEntityTypes.All, DefaultEntityTypes.AppOnly].find((f) => f.toString() === entity.type)) {
throw new CrmConfigurationError();
}
})
);
}
async function getContactsInRowIds(rowIds: string[], tenantId: string | null): Promise<ContactDto[]> {
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
const rows = await getRowsInIds(rowIds, tenantId);
return await Promise.all(
rowIds
.filter((f) => f)
.map(async (rowId) => {
const row = rows.find((i) => i.id === rowId);
if (!row) {
throw new Error("Contact not found: " + rowId);
}
const email = RowValueHelper.getText({ entity: contactsEntity, row, name: "email" }) ?? "";
const firstName = RowValueHelper.getText({ entity: contactsEntity, row, name: "firstName" }) ?? "";
const lastName = RowValueHelper.getText({ entity: contactsEntity, row, name: "lastName" }) ?? "";
const jobTitle = RowValueHelper.getText({ entity: contactsEntity, row, name: "jobTitle" }) ?? "";
const marketingSubscriber = RowValueHelper.getBoolean({ entity: contactsEntity, row, name: "marketingSubscriber" }) ?? false;
const contact: ContactDto = {
id: row.id,
email,
firstName,
lastName,
jobTitle,
marketingSubscriber,
};
return contact;
})
);
}
export type ContactDto = {
id: string;
email: string;
firstName: string;
lastName: string;
jobTitle: string;
marketingSubscriber: boolean;
company?: {
name: string;
};
};
async function getContact(rowId: string, tenantId: string | null): Promise<ContactDto | null> {
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
const row = await getRowById(rowId, { entityId: contactsEntity.id, tenantId });
if (!row) {
return null;
}
return rowToContact({ row, entity: contactsEntity });
}
async function getContacts(tenantId: string | null): Promise<ContactDto[]> {
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
const rows = await getRows({ entityName: "contact", tenantId });
return rows.map((row) => rowToContact({ row, entity: contactsEntity }));
}
export type ContactFormSettings = { crm: boolean; actionUrl?: string; error?: string };
async function getContactFormSettings() {
const settings: ContactFormSettings = {
crm: false,
actionUrl: undefined,
error: undefined,
};
try {
await validate();
settings.crm = true;
} catch {
const formspreeEndpoint = loadWebConfig().config.contactFormspreeEndpoint;
if (formspreeEndpoint) {
settings.actionUrl = formspreeEndpoint;
} else {
settings.error = i18nT("crm.contact.errors.formNotConfigured");
}
}
return settings;
}
export type NewsletterFormSettings = {
crm: boolean;
error?: string;
};
async function getNewsletterFormSettings() {
const settings: NewsletterFormSettings = {
crm: false,
error: undefined,
};
try {
await validate();
settings.crm = true;
} catch {
//
}
if (!settings.crm) {
settings.error = i18nT("crm.newsletter.errors.formNotConfigured");
}
return settings;
}
export type CrmSummaryDto = {
companies: number;
contacts: number;
opportunities: { value: number; count: number };
submissions: number;
data: {
openOpportunities: RowWithDetails[];
submissions: RowWithDetails[];
};
};
async function getSummary(tenantId: string | null): Promise<CrmSummaryDto> {
const openOpportunities = await getRows({
tenantId,
entityName: "opportunity",
});
const opportunities = { value: 0, count: 0 };
const opportunitiesEntity = await getEntityByName({ tenantId, name: "opportunity" });
openOpportunities.forEach((row) => {
const value = RowValueHelper.getNumber({ entity: opportunitiesEntity, row, name: "value" });
opportunities.value += value ?? 0;
if ((value ?? 0) > 0) {
opportunities.count++;
}
});
const submissionsEntity = await findEntityByName({ name: "submission", tenantId });
const submissions = submissionsEntity ? await getRows({ entityName: "submission", tenantId }) : [];
return {
companies: await countRows({ entityName: "company", tenantId }),
contacts: await countRows({ entityName: "contact", tenantId }),
opportunities,
submissions: submissions.length,
data: {
openOpportunities,
submissions,
},
};
}
async function createCompany({ tenantId, name, request }: { tenantId: string | null; name: string; request: Request }) {
const entity = await getEntityByName({ tenantId, name: "company" });
const rowValues = RowHelper.getRowPropertiesFromForm({
entity,
values: [{ name: "name", value: name }],
});
const row = await RowsApi.create({
entity,
tenantId,
rowValues,
request,
});
return row;
}
async function createContact({
tenantId,
firstName,
lastName,
email,
jobTitle,
status,
marketingSubscriber,
options,
request,
}: {
tenantId: string | null;
firstName: string;
lastName: string;
email: string;
jobTitle?: string;
status: string;
marketingSubscriber: boolean;
options?: {
checkUsage: boolean;
createLog: boolean;
createEvent: boolean;
storeMedia: boolean;
reportUsage: boolean;
};
request: Request;
}) {
const entity = await getEntityByName({ tenantId, name: "contact" });
const rowValues = RowHelper.getRowPropertiesFromForm({
entity,
values: [
{ name: "firstName", value: firstName },
{ name: "lastName", value: lastName },
{ name: "email", value: email },
{ name: "jobTitle", value: jobTitle ?? "" },
{ name: "status", value: status },
{ name: "marketingSubscriber", value: marketingSubscriber ? "true" : "false" },
],
options: {
skipValidation: true,
},
});
return await RowsApi.create({
entity,
tenantId,
rowValues,
options,
request,
});
}
async function createSubmission({ tenantId, users, message, request }: { tenantId: string | null; users: string; message: string; request: Request }) {
const entity = await getEntityByName({ tenantId, name: "submission" });
const rowValues = RowHelper.getRowPropertiesFromForm({
entity,
values: [
{ name: "users", value: users },
{ name: "message", value: message },
],
});
const row = await RowsApi.create({
entity,
tenantId,
rowValues,
request,
});
return row;
}
async function createContactSubmission(
submission: {
firstName: string;
lastName: string;
email: string;
jobTitle?: string;
company?: string;
users: string;
message: string;
},
request: Request
) {
let contact = await RowsApi.find({
entity: { name: "contact" },
tenantId: null,
properties: [{ name: "email", value: submission.email }],
});
if (!contact) {
contact = await createContact({
tenantId: null,
firstName: submission.firstName,
lastName: submission.lastName,
email: submission.email,
jobTitle: submission.jobTitle,
status: "contact",
marketingSubscriber: false,
request,
});
await RowsApi.addTag({ row: contact, tag: { value: "form", color: Colors.BLUE } });
}
const companyEntity = await getEntityByName({ tenantId: null, name: "company" });
const companies = contact.parentRows.filter((f) => f.parent.entityId === companyEntity.id);
if (companies.length === 0 && submission.company) {
const company = await createCompany({
tenantId: null,
name: submission.company,
request,
});
await RowRelationshipsApi.createRelationship({
parent: company,
child: contact,
tenantId: null,
});
}
const newSubmission = await createSubmission({
tenantId: null,
users: submission.users,
message: submission.message,
request,
});
await RowRelationshipsApi.createRelationship({
parent: contact,
child: newSubmission,
tenantId: null,
});
return contact;
}
async function updateContact(
rowId: string,
data: {
marketingSubscriber?: boolean;
firstName?: string;
lastName?: string;
jobTitle?: string;
},
options?: {
createEvent?: boolean;
tenantId?: string | null;
}
) {
const tenantId = options?.tenantId ?? null;
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
const row = await getRowById(rowId, { entityId: contactsEntity.id, tenantId });
if (!row) {
return null;
}
const contact = rowToContact({ row, entity: contactsEntity });
let changes: RowValueUpdateDto[] = [];
if (data.firstName !== undefined && data.firstName !== contact.firstName) {
changes.push({ name: "firstName", textValue: data.firstName });
} else if (data.lastName !== undefined && data.lastName !== contact.lastName) {
changes.push({ name: "lastName", textValue: data.lastName });
} else if (data.jobTitle !== undefined && data.jobTitle !== contact.jobTitle) {
changes.push({ name: "jobTitle", textValue: data.jobTitle });
} else if (data.marketingSubscriber !== undefined && data.marketingSubscriber !== contact.marketingSubscriber) {
changes.push({ name: "marketingSubscriber", booleanValue: data.marketingSubscriber });
}
if (changes.length === 0) {
return [];
}
await RowValueService.update({
entity: contactsEntity,
row,
values: changes,
session: { tenantId: row.tenantId },
checkPermissions: false,
options: {
createEvent: options?.createEvent,
},
});
return changes;
}
async function subscribeToNewsletter({
firstName,
lastName,
email,
source,
request,
}: {
firstName: string;
lastName: string;
email: string;
source?: string;
request: Request;
}) {
const settings = await getNewsletterFormSettings();
if (!settings.crm) {
return { error: i18nT("crm.newsletter.errors.integrationMissing") };
}
if (settings.crm) {
// Note: never log raw email/firstName/lastName here — those are PII.
crmLogger.info({ op: "newsletter.createContact", hasSource: Boolean(source) }, "Creating newsletter contact in CRM");
let contact = await RowsApi.find({
entity: { name: "contact" },
tenantId: null,
properties: [{ name: "email", value: email }],
});
if (!contact) {
contact = await createContact({
tenantId: null,
firstName,
lastName,
email: email,
jobTitle: "",
status: "contact",
marketingSubscriber: true,
request,
});
if (source) {
await RowsApi.addTag({ row: contact, tag: { value: source, color: Colors.ORANGE } });
} else {
await RowsApi.addTag({ row: contact, tag: { value: "newsletter", color: Colors.ORANGE } });
}
} else {
await updateContact(contact.id, { marketingSubscriber: true });
}
}
return { success: i18nT("crm.newsletter.subscribed") };
}
function rowToContact({ row, entity }: { row: RowWithDetails; entity: EntityWithDetails }) {
const email = RowValueHelper.getText({ entity, row, name: "email" }) ?? "";
const firstName = RowValueHelper.getText({ entity, row, name: "firstName" }) ?? "";
const lastName = RowValueHelper.getText({ entity, row, name: "lastName" }) ?? "";
const jobTitle = RowValueHelper.getText({ entity, row, name: "jobTitle" }) ?? "";
const marketingSubscriber = RowValueHelper.getBoolean({ entity, row, name: "marketingSubscriber" }) ?? false;
const companyRow = row.parentRows.find((f) => f.parent.entityId === entity.id)?.parent;
let company: { name: string } | undefined = undefined;
if (companyRow) {
company = {
name: RowValueHelper.getText({ entity, row: companyRow, name: "name" }) ?? "",
};
}
return {
id: row.id,
email,
firstName,
lastName,
jobTitle,
marketingSubscriber,
company,
};
}
export type UserInCrmDto = {
status: "synced" | "to-create" | "to-update";
email: string;
source: "user";
id: string;
firstName: string;
lastName: string;
isContact: boolean;
contact: ContactDto | null;
updatedAt: Date | null;
};
// EU-Sterilisation (R-P1a): invalidateCache wurde nur vom ConvertKit-Sync genutzt und bleibt als
// no-op-Parameter in der Signatur, damit Aufrufer (Admin-CRM-Sync) unverändert funktionieren.
async function getUsersInCrm(_options: { invalidateCache?: boolean } = {}): Promise<UserInCrmDto[]> {
const contacts = await getContacts(null);
let usersInCrm: UserInCrmDto[] = [];
usersInCrm.push(...(await loadFromUsers({ contacts })));
// filter by not contacts first, then by updated date desc
usersInCrm = usersInCrm.sort((a, b) => {
if (a.isContact && !b.isContact) {
return 1;
} else if (!a.isContact && b.isContact) {
return -1;
} else if (a.updatedAt && b.updatedAt) {
return b.updatedAt.getTime() - a.updatedAt.getTime();
} else if (a.updatedAt) {
return -1;
} else if (b.updatedAt) {
return 1;
}
return 0;
});
return usersInCrm;
}
async function loadFromUsers({ contacts }: { contacts: ContactDto[] }) {
let usersInCrm: UserInCrmDto[] = [];
// Read-only enumeration of users for CRM sync display — eventually-consistent
// is fine here. The sync action itself runs in a separate request that uses
// the write client.
const users = await dbRead.user.findMany({});
users.forEach((user) => {
const contact = contacts.find((f) => f.email === user.email) ?? null;
let status: "synced" | "to-create" | "to-update" = "synced";
if (!contact) {
status = "to-create";
} else if (contact.firstName !== user.firstName || contact.lastName !== user.lastName) {
status = "to-update";
}
const userInCrm: UserInCrmDto = {
status,
email: user.email,
source: "user",
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
updatedAt: user.updatedAt,
isContact: !!contact,
contact,
};
usersInCrm.push(userInCrm);
});
return usersInCrm;
}
export default {
validate,
getContactsInRowIds,
getContact,
getContacts,
getContactFormSettings,
getNewsletterFormSettings,
getSummary,
createCompany,
createContact,
createSubmission,
createContactSubmission,
updateContact,
subscribeToNewsletter,
getUsersInCrm,
};