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

450 lines
17 KiB
TypeScript

// WF-P8 M04 (Pipeline-Uebergabe) — hand a project into its successor pipeline
// together with the companion data the salesperson explicitly picked.
//
// Trigger (validated with David, see the D-P8 handoff): M04 is NOT a button.
// Moving a project into the final stage of its pipeline opens the dialog; only
// "Auswahl uebernehmen" runs `executeProjectHandover`.
//
// AD-20 shape: there is ONE `Projekt` row. The handover re-points
// `pipelineId`/`stageId` at the successor pipeline — it never copies the
// project. The picked notes/files/tasks/appointments are recorded as the
// handover PACKAGE (what the next team is explicitly handed); unpicked
// companion data stays on the project untouched. Nothing is deleted here.
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { logActivity } from "@hivegrid/activity";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { projectEditScopeWhere, type ProjectVisibilityContext } from "./projectService.server";
import { noteReadScopeWhere, type ProjektNotizContext } from "./projektNotizService.server";
export class PipelineHandoverServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "PipelineHandoverServiceError";
this.messageKey = messageKey;
}
}
export type HandoverItemKind = "notiz" | "datei" | "aufgabe" | "termin";
export const HANDOVER_ITEM_KINDS: readonly HandoverItemKind[] = ["notiz", "datei", "aufgabe", "termin"];
export function isHandoverItemKind(value: string): value is HandoverItemKind {
return (HANDOVER_ITEM_KINDS as readonly string[]).includes(value);
}
/**
* M04 posts its selection as a JSON array so one form field carries the whole
* pick. Unknown kinds and malformed payloads are dropped rather than failing
* the handover: the selection is additive metadata, not the move itself.
*
* Shared by both callers — the detail page and the board — so a payload that
* one accepts can never be one the other silently rejects.
*/
export function parseHandoverSelection(raw: string | null): { kind: HandoverItemKind; sourceId: string }[] {
if (!raw) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((entry) => {
if (typeof entry !== "object" || entry === null) return [];
const kind = (entry as { kind?: unknown }).kind;
const sourceId = (entry as { sourceId?: unknown }).sourceId;
if (typeof kind !== "string" || typeof sourceId !== "string" || !sourceId || !isHandoverItemKind(kind)) return [];
return [{ kind, sourceId }];
});
}
export type HandoverCandidate = {
id: string;
kind: HandoverItemKind;
/** Primary line (note headline, file name, task/appointment title). */
title: string;
/** Secondary line, where the record has one of its own (note body, description). */
body: string | null;
/** Raw user id — the view-model resolves it against a name directory. */
personId: string | null;
/** ISO timestamp used for ordering and for the "12.07. um 10:30 Uhr" style rendering. */
atIso: string | null;
/** Preselected in the dialog. */
defaultSelected: boolean;
/** datei */
fileExtension?: string | null;
fileRole?: string | null;
fileSizeBytes?: number | null;
/** aufgabe */
taskStatus?: string | null;
taskPriority?: string | null;
deadlineIso?: string | null;
/** termin */
appointmentType?: string | null;
};
export type HandoverPreview = {
projectId: string;
projectName: string;
fromPipelineId: string;
fromPipelineName: string;
toPipelineId: string;
toPipelineName: string;
/** Entry stage in the successor pipeline (its lowest-position open stage). */
toStageId: string;
toStageName: string;
candidates: HandoverCandidate[];
};
function excerpt(value: string, max = 120): string {
const collapsed = value.replace(/\s+/g, " ").trim();
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
}
/**
* A note is one text column, but M04 draws a headline plus a body. Split on the
* first line break: what a user typed as the opening line IS the headline. A
* one-line note keeps its body empty instead of repeating itself.
*/
function splitNote(inhalt: string): { title: string; body: string | null } {
const [first, ...rest] = inhalt.split(/\r?\n/);
const body = rest.join(" ").trim();
return { title: excerpt(first ?? "", 80), body: body ? excerpt(body) : null };
}
/**
* Badge text of a file row. The extension is what a user recognises ("PDF"),
* so it wins over the mime type; a file without one falls back to the mime
* subtype rather than showing an empty badge.
*/
function fileExtensionLabel(displayName: string, mimeType: string): string {
const dot = displayName.lastIndexOf(".");
const extension = dot > 0 ? displayName.slice(dot + 1) : "";
if (extension && extension.length <= 5) return extension.toUpperCase();
const subtype = mimeType.split("/")[1] ?? mimeType;
return subtype.toUpperCase();
}
/**
* Entry stage of the successor pipeline: lowest-position stage with
* `stageKind: "offen"`. A pipeline without an open stage cannot receive a
* handover — `validatePipelineStages` does not guarantee one exists.
*/
async function resolveEntryStageWithTx(tx: TenantRlsClient, input: { tenantId: string; pipelineId: string }) {
const stage = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, stageKind: "offen", archivedAt: null },
orderBy: { position: "asc" },
select: { id: true, name: true },
});
if (!stage) throw new PipelineHandoverServiceError("projects.error.handoverTargetHasNoOpenStage");
return stage;
}
/**
* True when moving `targetStageId` should open M04: the stage is the terminal
* one of its pipeline (highest position, or a `gewonnen` stage) AND the
* pipeline declares a successor. Callers use this to decide whether to render
* the dialog after a stage move.
*/
export async function shouldOpenHandoverWithTx(
tx: TenantRlsClient,
input: { tenantId: string; pipelineId: string; targetStageId: string }
): Promise<boolean> {
const pipeline = await tx.pipeline.findFirst({
where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null },
select: { nachfolgePipelineId: true },
});
if (!pipeline?.nachfolgePipelineId) return false;
const stage = await tx.pipelineStage.findFirst({
where: { id: input.targetStageId, tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null },
select: { id: true, stageKind: true, position: true },
});
if (!stage) return false;
if (stage.stageKind === "verloren") return false;
if (stage.stageKind === "gewonnen") return true;
const last = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null, stageKind: { not: "verloren" } },
orderBy: { position: "desc" },
select: { id: true },
});
return last?.id === stage.id;
}
/** Everything M04 needs to render: the target pipeline plus the pickable companion data. */
export async function loadHandoverPreview(input: {
tenantId: string;
projectId: string;
ctx: ProjektNotizContext;
}): Promise<HandoverPreview> {
return withTenantRLS(input.tenantId, async (tx) => {
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectEditScopeWhere(input.tenantId, input.ctx),
},
include: { pipeline: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const successorId = project.pipeline.nachfolgePipelineId;
if (!successorId) throw new PipelineHandoverServiceError("projects.error.handoverNoSuccessor");
const successor = await tx.pipeline.findFirst({
where: { id: successorId, tenantId: input.tenantId, archivedAt: null },
select: { id: true, name: true },
});
if (!successor) throw new PipelineHandoverServiceError("projects.error.handoverNoSuccessor");
const entryStage = await resolveEntryStageWithTx(tx, { tenantId: input.tenantId, pipelineId: successor.id });
// Notes honour the S02-N reader gate — a "nur ich" note of a different user
// must not become pickable here.
const [notes, fileLinks, tasks, appointments] = await Promise.all([
tx.projektNotiz.findMany({
where: { tenantId: input.tenantId, projektId: project.id, archivedAt: null, ...noteReadScopeWhere(input.ctx) },
orderBy: { createdAt: "desc" },
take: 50,
}),
tx.fileEntityLink.findMany({
where: { tenantId: input.tenantId, entityType: "project", entityId: project.id },
include: { file: { select: { id: true, displayName: true, mimeType: true, sizeBytes: true, createdAt: true, archivedAt: true } } },
orderBy: { createdAt: "desc" },
take: 50,
}),
// Tasks attach polymorphically, not through a `projektId` column.
tx.task.findMany({
where: { tenantId: input.tenantId, linkedEntityType: "project", linkedEntityId: project.id, archivedAt: null },
orderBy: { createdAt: "desc" },
take: 50,
}),
tx.projektTermin.findMany({
where: { tenantId: input.tenantId, projektId: project.id, archivedAt: null },
orderBy: { startAt: "asc" },
take: 50,
}),
]);
const candidates: HandoverCandidate[] = [
...notes.map((note) => ({
id: note.id,
kind: "notiz" as const,
...splitNote(note.inhalt),
personId: note.autorUserId,
atIso: note.createdAt.toISOString(),
// Everything is preselected: the dialog is a "deselect what must not
// travel" step, which matches the counts shown in Davids screens.
defaultSelected: true,
})),
...fileLinks
.filter((link) => link.file && !link.file.archivedAt)
.map((link) => ({
id: link.file.id,
kind: "datei" as const,
title: link.file.displayName,
body: null,
personId: null,
atIso: link.file.createdAt.toISOString(),
defaultSelected: true,
fileExtension: fileExtensionLabel(link.file.displayName, link.file.mimeType),
fileRole: link.linkRole,
fileSizeBytes: link.file.sizeBytes,
})),
...tasks.map((task) => ({
id: task.id,
kind: "aufgabe" as const,
title: task.title,
body: task.description ? excerpt(task.description) : null,
personId: task.assignedToUserId,
atIso: task.deadline ? task.deadline.toISOString() : task.createdAt.toISOString(),
defaultSelected: true,
taskStatus: task.status,
taskPriority: task.priority,
deadlineIso: task.deadline ? task.deadline.toISOString() : null,
})),
...appointments.map((termin) => ({
id: termin.id,
kind: "termin" as const,
title: termin.titel,
body: termin.beschreibung ? excerpt(termin.beschreibung) : null,
personId: termin.assignedToUserId,
atIso: termin.startAt.toISOString(),
defaultSelected: true,
appointmentType: termin.typ,
})),
];
return {
projectId: project.id,
projectName: project.projektname,
fromPipelineId: project.pipelineId,
fromPipelineName: project.pipeline.name,
toPipelineId: successor.id,
toPipelineName: successor.name,
toStageId: entryStage.id,
toStageName: entryStage.name,
candidates,
};
});
}
/**
* Commit the handover: record the package, then re-point the project at the
* successor pipeline's entry stage. Both happen in one transaction so a project
* can never end up in a pipeline without its handover trail.
*/
export async function executeProjectHandover(input: {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
/** The ticked companion records, as `{kind, sourceId}` pairs. */
selection: readonly { kind: HandoverItemKind; sourceId: string }[];
}): Promise<{ handoverId: string; toPipelineId: string; toStageId: string }> {
return withTenantRLS(input.tenantId, async (tx) => {
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectEditScopeWhere(input.tenantId, input.ctx),
},
include: { pipeline: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const successorId = project.pipeline.nachfolgePipelineId;
if (!successorId) throw new PipelineHandoverServiceError("projects.error.handoverNoSuccessor");
const successor = await tx.pipeline.findFirst({
where: { id: successorId, tenantId: input.tenantId, archivedAt: null },
select: { id: true, name: true },
});
if (!successor) throw new PipelineHandoverServiceError("projects.error.handoverNoSuccessor");
const entryStage = await resolveEntryStageWithTx(tx, { tenantId: input.tenantId, pipelineId: successor.id });
const handover = await tx.pipelineHandover.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
fromPipelineId: project.pipelineId,
toPipelineId: successor.id,
fromStageId: project.stageId,
toStageId: entryStage.id,
performedByUserId: input.userId,
},
});
// Dedupe defensively: the dialog can submit the same id twice if a user
// double-clicks, and the unique index would otherwise abort the whole move.
const seen = new Set<string>();
const items = input.selection.filter((item) => {
const key = `${item.kind}:${item.sourceId}`;
if (seen.has(key) || !item.sourceId) return false;
seen.add(key);
return true;
});
if (items.length > 0) {
await tx.pipelineHandoverItem.createMany({
data: items.map((item) => ({
tenantId: input.tenantId,
handoverId: handover.id,
kind: item.kind,
sourceId: item.sourceId,
})),
});
}
await tx.projekt.update({
where: { id: project.id, tenantId: input.tenantId },
data: {
pipelineId: successor.id,
stageId: entryStage.id,
// Entering a fresh pipeline clears the terminal markers of the old one:
// the project is open again in the new view (AD-20, no second row).
wonAt: null,
lostAt: null,
lostReasonId: null,
undoStageChangeUntil: null,
},
});
await tx.projektStageChange.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
fromStageId: project.stageId,
toStageId: entryStage.id,
changedByUserId: input.userId,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_pipeline_handover",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.handoverCompleted",
metadata: {
handoverId: handover.id,
fromPipelineId: project.pipelineId,
toPipelineId: successor.id,
itemCount: items.length,
},
},
{ client: tx }
);
return { handoverId: handover.id, toPipelineId: successor.id, toStageId: entryStage.id };
});
}
/**
* The M04 payload when the project currently awaits a handover, otherwise null.
*
* "Awaits a handover" = it sits in the final stage of a pipeline that declares
* a successor. Every not-applicable case (no successor, successor without an
* open stage, project not editable) resolves to null rather than throwing: the
* detail page must render fine when no handover is pending.
*/
export async function loadPendingHandover(input: {
tenantId: string;
projectId: string;
ctx: ProjektNotizContext;
}): Promise<HandoverPreview | null> {
const pending = await withTenantRLS(input.tenantId, async (tx) => {
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectEditScopeWhere(input.tenantId, input.ctx),
},
select: { pipelineId: true, stageId: true },
});
if (!project) return false;
return shouldOpenHandoverWithTx(tx, {
tenantId: input.tenantId,
pipelineId: project.pipelineId,
targetStageId: project.stageId,
});
});
if (!pending) return null;
try {
return await loadHandoverPreview(input);
} catch {
return null;
}
}
/** Handover trail of a project, newest first (rendered in the S02 "Verlauf" sidebar area). */
export async function listProjectHandovers(input: { tenantId: string; projectId: string }) {
return withTenantRLS(input.tenantId, async (tx) =>
tx.pipelineHandover.findMany({
where: { tenantId: input.tenantId, projektId: input.projectId },
include: { items: true },
orderBy: { createdAt: "desc" },
})
);
}