196 lines
8.2 KiB
TypeScript
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,
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
}
|