450 lines
17 KiB
TypeScript
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" },
|
|
})
|
|
);
|
|
}
|