Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/fileLinkTargets.server.ts
T

196 lines
8.2 KiB
TypeScript

// P4 Files — candidates for the "Datei verknüpfen" dialog.
//
// The dialog used to ask for a raw record id, which nobody carries in their
// head. This resolves the pickable records per entity type instead: one small
// query per type, tenant-scoped through RLS, newest first, narrowed by a
// substring search over whatever that record is recognised by.
//
// Not every linkable type has something to search by (an appointment has no
// title of its own, a form submission no name), so the set of searchable types
// is declared here and the dialog falls back to the plain id field for the rest.
// Better an honest "no picker for this type" than a picker that lists nothing.
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import type { FileEntityType } from "./fileService.server";
/** One pickable record: what gets linked, and how a human recognises it. */
export type FileLinkTarget = { id: string; label: string; hint: string | null };
/** Entity types the picker can search. Everything else keeps the id field. */
export const SEARCHABLE_LINK_TYPES = ["contact", "company", "lead", "project", "task", "user"] as const;
export type SearchableLinkType = (typeof SEARCHABLE_LINK_TYPES)[number];
export function isSearchableLinkType(value: string): value is SearchableLinkType {
return (SEARCHABLE_LINK_TYPES as readonly string[]).includes(value);
}
/** Enough rows to pick from, few enough to stay a single cheap query. */
const RESULT_LIMIT = 20;
function contains(search: string) {
return { contains: search, mode: "insensitive" as const };
}
/** Join the parts of a name that are actually there, without stray separators. */
function joinLabel(parts: readonly (string | null | undefined)[], fallback: string): string {
const label = parts.map((part) => part?.trim()).filter(Boolean).join(" ");
return label || fallback;
}
/** Key of one link, so a resolved label can be looked up per (type, id). */
export function fileLinkKey(entityType: string, entityId: string): string {
return `${entityType}:${entityId}`;
}
/**
* Names for records that are already linked, so the list of links reads
* "Kontakt · Markus Weber" instead of just "Kontakt". A record that no longer
* exists (or has no searchable name) simply has no label — the list falls back
* to the type, which is what it showed before.
*/
export async function resolveFileLinkLabels(input: {
tenantId: string;
links: readonly { entityType: string; entityId: string }[];
}): Promise<Map<string, string>> {
const labels = new Map<string, string>();
const byType = new Map<SearchableLinkType, string[]>();
for (const link of input.links) {
if (!isSearchableLinkType(link.entityType)) continue;
const ids = byType.get(link.entityType) ?? [];
ids.push(link.entityId);
byType.set(link.entityType, ids);
}
if (byType.size === 0) return labels;
await Promise.all(
[...byType.entries()].map(async ([entityType, ids]) => {
// Reuse the picker's own labelling, so a link reads exactly like the entry
// it was picked from.
const rows = await searchFileLinkTargets({ tenantId: input.tenantId, entityType, search: "", ids });
for (const row of rows) labels.set(fileLinkKey(entityType, row.id), row.label);
})
);
return labels;
}
/**
* Records of `entityType` the current user may link this file to, filtered by
* `search`, or exactly the records in `ids` when the caller is resolving known
* links. An empty search returns the newest records rather than nothing:
* opening the dialog should show something to click.
*
* Visibility note: this runs inside the tenant's RLS context, so the rows are
* tenant-scoped by the database. Per-record read scopes (project pipelines, CRM
* owner scope) are deliberately NOT re-implemented here — linking is a
* `files.write` action on the file, and the label of a tenant record is not the
* record. The link itself is written by `linkFile`, which keeps its own checks.
*/
export async function searchFileLinkTargets(input: {
tenantId: string;
entityType: FileEntityType;
search: string;
/** Restrict to these records (label resolution) instead of searching. */
ids?: readonly string[];
}): Promise<FileLinkTarget[]> {
const { tenantId, entityType } = input;
const search = input.search.trim();
if (!isSearchableLinkType(entityType)) return [];
if (input.ids && input.ids.length === 0) return [];
/** Restriction to the requested ids, merged into every model's where clause. */
const onlyIds = input.ids ? { id: { in: [...input.ids] } } : {};
return withTenantRLS(tenantId, async (tx: TenantRlsClient) => {
switch (entityType) {
case "contact":
case "company": {
// One model carries both: a Kontakt is either a person or an
// organisation (AD-20), so "company" is the organisation half of it.
const kontaktTyp = entityType === "company" ? "organisation" : "privatperson";
const rows = await tx.kontakt.findMany({
where: {
tenantId,
kontaktTyp,
...onlyIds,
...(search
? {
OR: [
{ vorname: contains(search) },
{ nachname: contains(search) },
{ orgName: contains(search) },
{ primaerEmail: contains(search) },
],
}
: {}),
},
orderBy: { createdAt: "desc" },
take: RESULT_LIMIT,
select: { id: true, vorname: true, nachname: true, orgName: true, primaerEmail: true },
});
return rows.map((row) => ({
id: row.id,
label: joinLabel(entityType === "company" ? [row.orgName] : [row.vorname, row.nachname], row.primaerEmail ?? row.id),
hint: row.primaerEmail ?? null,
}));
}
case "lead": {
// The tenant's own lead inbox (P7). `Lead` in marketingLeads is the
// platform's acquisition lead and has no tenant at all.
const rows = await tx.inboxLead.findMany({
where: {
tenantId,
...onlyIds,
...(search ? { OR: [{ vorname: contains(search) }, { nachname: contains(search) }, { email: contains(search) }] } : {}),
},
orderBy: { createdAt: "desc" },
take: RESULT_LIMIT,
select: { id: true, vorname: true, nachname: true, email: true },
});
return rows.map((row) => ({
id: row.id,
label: joinLabel([row.vorname, row.nachname], row.email ?? row.id),
hint: row.email,
}));
}
case "project": {
const rows = await tx.projekt.findMany({
where: {
tenantId,
...onlyIds,
...(search ? { OR: [{ projektname: contains(search) }, { projektNummer: contains(search) }] } : {}),
},
orderBy: { createdAt: "desc" },
take: RESULT_LIMIT,
select: { id: true, projektname: true, projektNummer: true },
});
return rows.map((row) => ({ id: row.id, label: row.projektname, hint: row.projektNummer }));
}
case "task": {
const rows = await tx.task.findMany({
where: { tenantId, ...onlyIds, ...(search ? { title: contains(search) } : {}) },
orderBy: { createdAt: "desc" },
take: RESULT_LIMIT,
select: { id: true, title: true },
});
return rows.map((row) => ({ id: row.id, label: row.title, hint: null }));
}
case "user": {
// Members of this tenant, not every user row in the database.
const rows = await tx.tenantUser.findMany({
where: {
tenantId,
...(input.ids ? { userId: { in: [...input.ids] } } : {}),
...(search ? { user: { is: { OR: [{ firstName: contains(search) }, { lastName: contains(search) }, { email: contains(search) }] } } } : {}),
},
take: RESULT_LIMIT,
select: { user: { select: { id: true, firstName: true, lastName: true, email: true } } },
});
return rows.map((row) => ({
id: row.user.id,
label: joinLabel([row.user.firstName, row.user.lastName], row.user.email),
hint: row.user.email,
}));
}
}
});
}