551 lines
16 KiB
TypeScript
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,
|
|
};
|