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

4277 lines
169 KiB
TypeScript

import { buildPortalStatusMailOutboxEvent, CatalogError, foundationErrors, massActionErrors, type JsonValue } from "@hivegrid/foundation";
import { neutralizeSpreadsheetFormula } from "@hivegrid/foundation/spreadsheet-safety";
import { randomUUID } from "node:crypto";
import { Prisma } from "@hivegrid/db/prisma-browser";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import { buildWorkflowTriggerOutboxEvent, type QueueJsonValue } from "@hivegrid/queue";
import { logActivity } from "@hivegrid/activity";
import { ensureAndSyncProjectChannelIfEnabledWithTx } from "@hivegrid/chat/project-sync.server";
import { assertKwpProjectMutationAllowed } from "@hivegrid/integrations/kwp-project-domain.server";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { resolveUserLabels } from "~/utils/integrations/taskService.server";
import { publishLeadSearchUpsert, publishProjectSearchUpsert } from "~/services/search/searchIndexProjection.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { createScopedLogger } from "~/utils/logger.server";
import { DEFAULT_SALES_LOST_REASONS, DEFAULT_SALES_PIPELINE_STAGES } from "./salesPipelineDefaults";
import {
buildOwnerWhere,
buildPeriodWhere,
buildValueBandWhere,
resolvePeriodRanges,
type ProjectCustomerType,
type ProjectFacetFilters,
} from "./projectFilterFacets";
export class ProjectServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "ProjectServiceError";
this.messageKey = messageKey;
}
}
export type PipelineTypHint = "sales" | "auftrag" | "custom";
export type PipelineStageKind = "offen" | "gewonnen" | "verloren";
export type PipelinePrincipalType = "role" | "group" | "user";
export type ProjektPrioritaet = "niedrig" | "normal" | "hoch" | "dringend";
export type ProjektKontaktRolle = "auftraggeber" | "bauherr" | "rechnungsempfaenger" | "ansprechpartner";
export type KontaktTyp = "privatperson" | "organisation";
export type ProjectVisibilityContext = {
userId: string;
permissions: readonly string[];
roleIds?: readonly string[];
groupIds?: readonly string[];
};
export function canReadAllProjects(ctx: ProjectVisibilityContext): boolean {
return ctx.permissions.includes("projects.read_all");
}
export function canReadProjectLead(ctx: ProjectVisibilityContext): boolean {
return ctx.permissions.includes("projects.read_own_pl") || canReadAllProjects(ctx);
}
type ProjectVisibilityCondition = Record<string, unknown>;
function projectVisibilityConditions(tenantId: string, ctx: ProjectVisibilityContext, includeReadAllFallback: boolean): ProjectVisibilityCondition[] {
const conditions: ProjectVisibilityCondition[] = [];
if (ctx.permissions.includes("projects.read_own") || (includeReadAllFallback && canReadAllProjects(ctx))) {
conditions.push({ zustaendigePersonId: ctx.userId });
}
if (ctx.permissions.includes("projects.read_own_pl") || (includeReadAllFallback && canReadAllProjects(ctx))) {
conditions.push({ projektleiterId: ctx.userId });
}
if (ctx.permissions.includes("projects.read_own_member")) {
conditions.push({
mitglieder: {
some: {
tenantId,
archivedAt: null,
OR: [{ userId: ctx.userId }, ...((ctx.groupIds?.length ?? 0) > 0 ? [{ groupId: { in: [...(ctx.groupIds ?? [])] } }] : [])],
},
},
});
}
return conditions;
}
export function ownerScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
if (canReadAllProjects(ctx)) return {};
const conditions = projectVisibilityConditions(tenantId, ctx, false);
return conditions.length > 0 ? { OR: conditions } : { id: { in: [] } };
}
function mineScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
const conditions = projectVisibilityConditions(tenantId, ctx, true);
return conditions.length > 0 ? { OR: conditions } : { id: { in: [] } };
}
export function effectivePipelinePrincipals(ctx: ProjectVisibilityContext): Array<{ principalType: PipelinePrincipalType; principalId: string }> {
return [
{ principalType: "user", principalId: ctx.userId },
...(ctx.roleIds ?? []).map((principalId) => ({ principalType: "role" as const, principalId })),
...(ctx.groupIds ?? []).map((principalId) => ({ principalType: "group" as const, principalId })),
];
}
export function pipelineVisibilityScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
return {
accessRules: {
none: {
tenantId,
kannSehen: false,
OR: effectivePipelinePrincipals(ctx),
},
},
};
}
function deniedPipelineScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
return { pipeline: pipelineVisibilityScopeWhere(tenantId, ctx) };
}
function pipelineEditabilityScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
return {
accessRules: {
none: {
tenantId,
AND: [{ OR: effectivePipelinePrincipals(ctx) }, { OR: [{ kannSehen: false }, { kannBearbeiten: false }] }],
},
},
};
}
function editablePipelineScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
return { pipeline: pipelineEditabilityScopeWhere(tenantId, ctx) };
}
export function projectReadScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
const ownerScope = ownerScopeWhere(tenantId, ctx);
const pipelineScope = deniedPipelineScopeWhere(tenantId, ctx);
return Object.keys(ownerScope).length === 0 ? pipelineScope : { AND: [ownerScope, pipelineScope] };
}
export function projectEditScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record<string, unknown> {
const ownerScope = ownerScopeWhere(tenantId, ctx);
const pipelineScope = editablePipelineScopeWhere(tenantId, ctx);
return Object.keys(ownerScope).length === 0 ? pipelineScope : { AND: [ownerScope, pipelineScope] };
}
export type ProjektRow = {
id: string;
tenantId: string;
createdByUserId: string | null;
projektNummer: string;
projektname: string;
beschreibung: string | null;
projekttyp: string | null;
quelle: string | null;
/** WF-P8 S02 "Grunddaten" / "Projektkontaktdaten". */
promotion: string | null;
email: string | null;
telefon: string | null;
fax: string | null;
prioritaet: ProjektPrioritaet;
pipelineId: string;
stageId: string;
potenziellerAuftragswert: unknown;
auftragsvolumen: unknown;
startDatum: Date | null;
deadline: Date | null;
zustaendigePersonId: string | null;
projektleiterId: string | null;
projektAdresseId: string | null;
rechnungsAdresseId: string | null;
bauherrAdresseId: string | null;
konvertiertAusLeadId: string | null;
lostReasonId: string | null;
wonAt: Date | null;
lostAt: Date | null;
undoStageChangeUntil: Date | null;
redoStageId: string | null;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
export type PipelineRow = {
id: string;
tenantId: string;
name: string;
typHint: PipelineTypHint;
position: number;
isDefault: boolean;
/** WF-P8 M04 successor pipeline; null = terminal board. */
nachfolgePipelineId: string | null;
isDemoData: boolean;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type PipelineStageRow = {
id: string;
tenantId: string;
pipelineId: string;
name: string;
stageKind: PipelineStageKind;
winProbability: number;
color: string;
position: number;
isDemoData: boolean;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type LostReasonRow = {
id: string;
tenantId: string;
name: string;
/** WF-P8 S06 admin table: one-line explanation. */
beschreibung: string | null;
/** WF-P8 S06 admin table: free-text grouping. */
kategorie: string | null;
/** WF-P8 S06 admin table: shipped SHK template vs. tenant-created reason. */
istVorlage: boolean;
position: number;
isDemoData: boolean;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
/** WF-P8 S06: one admin row — the reason plus the numbers the table shows. */
export type LostReasonAdminRow = LostReasonRow & {
/** Projects currently pointing at this reason. */
projectCount: number;
/** Last time a project was actually moved to lost with this reason. */
lastUsedAt: Date | null;
};
/** WF-P8 S06 header box: the loss picture of the last 30 days. */
export type LostReasonInsights = {
/** Name of the most-used reason in the last 30 days, or null when nothing was lost. */
topReasonName: string | null;
/** Losses in the last 30 days. */
recentLossCount: number;
/** Losses in the 30 days before that — the comparison the header shows. */
previousLossCount: number;
};
export type ProjektStageChangeRow = {
id: string;
tenantId: string;
projektId: string;
fromStageId: string;
toStageId: string;
lostReasonId: string | null;
changedByUserId: string | null;
undoUntil: Date | null;
undoneAt: Date | null;
createdAt: Date;
};
export type PipelineAggregateSnapshotRow = {
id: string;
tenantId: string;
pipelineId: string;
stageId: string | null;
scopeKey: string;
projectCount: number;
potentialValue: unknown;
orderVolume: unknown;
weightedForecast: unknown;
realizedRevenue: unknown;
lostCount: number;
stageConversionRate: unknown;
lostReasonDistribution: unknown;
isDemoDataIncluded: boolean;
calculatedAt: Date;
};
export type ProjectBoardCard = {
id: string;
projektNummer: string;
projektname: string;
prioritaet: ProjektPrioritaet;
value: string | null;
valueVisible: boolean;
ownerUserId: string | null;
projektleiterId: string | null;
deadline: string | null;
undoStageChangeUntil: string | null;
/** WF-P8 M03: stage an undo moved this project away from — the redo target. */
redoStageId: string | null;
isStuck: boolean;
/** ISO timestamp the project entered its current stage (fallback: project createdAt). */
inStageSince: string;
/** WF-P8 S01 card subtitle: primary contact of the project, if any. */
kontaktLabel: string | null;
/** WF-P8 S01 card subtitle: city of the project address, if any. */
ort: string | null;
/** WF-P8 S01 card tag: the tenant-defined project type. */
projekttyp: string | null;
/** WF-P8 S01 card date range start. */
startDatum: string | null;
/** WF-P8 S01 card appointment pill: the next upcoming appointment, if any. */
nextTerminTitel: string | null;
nextTerminStartAt: string | null;
/** WF-P8 S01 card chips from the shared `Tag` catalogue. */
tags: { id: string; name: string; farbe: string }[];
};
export type ProjectBoardColumn = {
stage: PipelineStageRow;
projectCount: number;
aggregateValue: string | null;
aggregateVisible: boolean;
cards: ProjectBoardCard[];
};
export type ProjectBoardVM = {
pipeline: PipelineRow;
pipelines: PipelineRow[];
columns: ProjectBoardColumn[];
generatedAt: string;
};
export type ProjectListFilters = {
pipelineId?: string | null;
stageId?: string | null;
ownerUserId?: string | null;
q?: string | null;
onlyMine?: boolean;
includeDemoData?: boolean;
/** WF-P8 S01 facets. `facetNow` anchors the relative period options. */
facets?: ProjectFacetFilters;
facetNow?: Date;
/**
* Contact ids matching the selected customer types, pre-resolved by
* `resolveCustomerTypeKontaktIdsWithTx` — `where` cannot join Kontakt itself
* (ProjektKontakt stores a bare `kontaktId` without a relation). `null` means
* "customer type not filtered"; an empty array means "filtered, nothing
* matched", which must yield no projects rather than all of them.
*/
customerTypeKontaktIds?: readonly string[] | null;
/**
* Project ids carrying one of the selected tags, pre-resolved by
* `resolveTagProjektIdsWithTx`. `TagAssignment.entityId` has no foreign key to
* `Projekt` (the table is polymorphic), so the filter cannot be a nested one.
* `null` means "tags not filtered"; an empty array means "filtered, nothing
* matched" and must yield no projects.
*/
tagProjektIds?: readonly string[] | null;
};
export type ProjectListSort = "updated_desc" | "updated_asc" | "name_asc" | "name_desc" | "number_asc" | "value_desc";
export type ProjectListRow = {
id: string;
projektNummer: string;
projektname: string;
pipelineName: string;
stageName: string;
value: string | null;
valueVisible: boolean;
ownerUserId: string | null;
projektleiterId: string | null;
updatedAt: string;
/** WF-P8 S03 companions — same batch the board card uses. */
kontaktLabel: string | null;
ort: string | null;
projekttyp: string | null;
nextTerminTitel: string | null;
nextTerminStartAt: string | null;
tags: { id: string; name: string; farbe: string }[];
};
export type ProjectListResult = {
rows: ProjectListRow[];
total: number;
page: number;
pageSize: number;
};
export type PipelineForecastVM = {
pipelineId: string;
aggregateVisible: boolean;
projectCount: number;
potentialValue: string | null;
orderVolume: string | null;
weightedForecast: string | null;
realizedRevenue: string | null;
lostCount: number;
stageConversionRates: Array<{ stageId: string; stageName: string; rate: number | null }>;
lostReasonDistribution: Record<string, number>;
/** KPIs per responsible user (zustaendigePersonId) from the owner:<userId> cache scope. */
ownerKpis: Array<{
ownerUserId: string;
projectCount: number;
weightedForecast: string | null;
realizedRevenue: string | null;
lostCount: number;
}>;
calculatedAt: string;
};
export type CreatePipelineStageInput = {
name: string;
stageKind: PipelineStageKind;
winProbability?: number;
color?: string;
position?: number;
};
export type PipelineAccessInput = {
principalType: PipelinePrincipalType;
principalId: string;
kannSehen?: boolean;
kannBearbeiten?: boolean;
};
export type PipelineFieldConfigInput = {
id?: string;
stageId?: string | null;
fieldKey: string;
sichtbar?: boolean;
pflicht?: boolean;
reihenfolge?: number;
// WF-P8 S05 detail-template layout. Undefined leaves the stored value alone
// so the older callers (pipeline admin) keep working unchanged; null clears.
groupId?: string | null;
pflichtAbStageId?: string | null;
hinweis?: string | null;
istSystem?: boolean;
gesperrt?: boolean;
};
export type PipelineFieldVisibilityInput = {
fieldConfigId: string;
principalType: PipelinePrincipalType;
principalId: string;
kannSehen?: boolean;
kannBearbeiten?: boolean;
};
function normalizeText(value?: string | null): string | null {
const normalized = value?.trim();
return normalized || null;
}
async function assertPipelineOwnedWithTx(tx: TenantRlsClient, tenantId: string, pipelineId: string): Promise<void> {
const pipeline = await tx.pipeline.findFirst({
where: { id: pipelineId, tenantId, archivedAt: null },
select: { id: true },
});
if (!pipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId });
}
async function assertPipelineStageOwnedWithTx(tx: TenantRlsClient, input: { tenantId: string; pipelineId: string; stageId: string }): Promise<void> {
const stage = await tx.pipelineStage.findFirst({
where: {
id: input.stageId,
tenantId: input.tenantId,
pipelineId: input.pipelineId,
archivedAt: null,
pipeline: { tenantId: input.tenantId, archivedAt: null },
},
select: { id: true },
});
if (!stage) {
throw new CatalogError(foundationErrors.notFound, {
pipelineId: input.pipelineId,
stageId: input.stageId,
});
}
}
async function assertPipelinePrincipalOwnedWithTx(
tx: TenantRlsClient,
input: { tenantId: string; principalType: PipelinePrincipalType; principalId: string }
): Promise<void> {
const principalId = normalizeText(input.principalId);
if (!principalId) throw new CatalogError(foundationErrors.notFound, { principalType: input.principalType });
let principal: { id: string } | null;
switch (input.principalType) {
case "role":
principal = await tx.role.findFirst({
where: { id: principalId, tenantId: input.tenantId },
select: { id: true },
});
break;
case "group":
principal = await tx.group.findFirst({
where: { id: principalId, tenantId: input.tenantId },
select: { id: true },
});
break;
case "user": {
const membership = await tx.tenantUser.findFirst({
where: {
tenantId: input.tenantId,
userId: principalId,
status: TenantUserStatus.ACTIVE,
user: { active: true },
},
select: { userId: true },
});
principal = membership ? { id: membership.userId } : null;
break;
}
default:
principal = null;
}
if (!principal) {
throw new CatalogError(foundationErrors.notFound, {
principalType: input.principalType,
principalId,
});
}
}
async function assertTenantUsersOwnedWithTx(
tx: TenantRlsClient,
tenantId: string,
references: ReadonlyArray<{ userId: string | null; field: string }>
): Promise<void> {
const referenceByUserId = new Map(
references
.filter((reference): reference is { userId: string; field: string } => Boolean(reference.userId))
.map((reference) => [reference.userId, reference])
);
if (referenceByUserId.size === 0) return;
const memberships = await tx.tenantUser.findMany({
where: {
tenantId,
userId: { in: [...referenceByUserId.keys()] },
status: TenantUserStatus.ACTIVE,
user: { active: true },
},
select: { userId: true },
});
const found = new Set(memberships.map((membership) => membership.userId));
for (const [userId, reference] of referenceByUserId) {
if (!found.has(userId)) throw new CatalogError(foundationErrors.notFound, { [reference.field]: userId });
}
}
function hasPermission(ctx: ProjectVisibilityContext | undefined, permission: string): boolean {
return ctx?.permissions.includes(permission) ?? false;
}
function isProjectOwner(project: Pick<ProjektRow, "zustaendigePersonId" | "projektleiterId">, ctx: ProjectVisibilityContext | undefined): boolean {
if (!ctx) return false;
return project.zustaendigePersonId === ctx.userId || project.projektleiterId === ctx.userId;
}
function decimalToNumber(value: unknown): number {
if (value == null) return 0;
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
if (typeof value === "object" && "toNumber" in value && typeof value.toNumber === "function") {
const parsed = value.toNumber();
return Number.isFinite(parsed) ? parsed : 0;
}
if (typeof value === "object" && "toString" in value && typeof value.toString === "function") {
const parsed = Number(value.toString());
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function decimalToString(value: unknown): string {
return decimalToNumber(value).toFixed(2);
}
function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
function projectListWhere(input: { tenantId: string; ctx?: ProjectVisibilityContext; filters?: ProjectListFilters }): Record<string, unknown> {
const filters = input.filters;
const where: Record<string, unknown> = {
tenantId: input.tenantId,
archivedAt: null,
...(filters?.includeDemoData ? {} : { isDemoData: false }),
...(input.ctx ? projectReadScopeWhere(input.tenantId, input.ctx) : {}),
};
if (filters?.pipelineId) where.pipelineId = filters.pipelineId;
if (filters?.stageId) where.stageId = filters.stageId;
if (filters?.ownerUserId) where.zustaendigePersonId = filters.ownerUserId;
if (filters?.onlyMine && input.ctx) {
delete where.OR;
Object.assign(where, mineScopeWhere(input.tenantId, input.ctx));
}
// Every additional predicate goes into AND, never onto the top level: the
// scope fragments above already own `OR`, and overwriting it would widen the
// visibility scope instead of narrowing the result.
const extra: Record<string, unknown>[] = [];
const q = normalizeText(filters?.q);
if (q) {
extra.push({
OR: [{ projektname: { contains: q, mode: "insensitive" } }, { projektNummer: { contains: q, mode: "insensitive" } }],
});
}
const facets = filters?.facets;
if (facets) {
const owner = buildOwnerWhere(facets.owners ?? []);
if (owner) extra.push(owner);
const period = resolvePeriodRanges(facets.periods, filters?.facetNow ?? new Date(), {
from: facets.periodFrom,
to: facets.periodTo,
});
if (period) extra.push(buildPeriodWhere(period));
const value = buildValueBandWhere(facets.valueBands ?? []);
if (value) extra.push(value);
}
if (filters?.customerTypeKontaktIds != null) {
extra.push({ kontakte: { some: { kontaktId: { in: [...filters.customerTypeKontaktIds] } } } });
}
if (filters?.tagProjektIds != null) {
extra.push({ id: { in: [...filters.tagProjektIds] } });
}
if (extra.length > 0) {
const existingAnd = Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : [];
where.AND = [...existingAnd, ...extra];
}
return where;
}
/**
* Resolve the "Kundentyp" facet into the contact ids it selects.
*
* Runs as its own query because `ProjektKontakt` only stores `kontaktId` — the
* schema has no relation to `Kontakt`, so a nested filter is not available.
* Returns `null` when the facet is not in use, so callers can tell "no filter"
* apart from "filter matched nothing".
*/
/**
* Resolve the "Tags" facet into the project ids it selects.
*
* `TagAssignment` is polymorphic (`entityType` + bare `entityId`), so this is a
* separate query rather than a nested filter. Returns `null` when the facet is
* unused, so callers can tell "no filter" from "filter matched nothing".
*/
async function resolveTagProjektIdsWithTx(
tx: TenantRlsClient,
input: { tenantId: string; tagIds: readonly string[] }
): Promise<string[] | null> {
if (input.tagIds.length === 0) return null;
const assignments = await tx.tagAssignment.findMany({
where: { tenantId: input.tenantId, entityType: "projekt", tagId: { in: [...input.tagIds] } },
select: { entityId: true },
});
return [...new Set(assignments.map((assignment) => assignment.entityId))];
}
async function resolveCustomerTypeKontaktIdsWithTx(
tx: TenantRlsClient,
input: { tenantId: string; customerTypes: readonly ProjectCustomerType[] }
): Promise<string[] | null> {
if (input.customerTypes.length === 0) return null;
const alternatives: Record<string, unknown>[] = [];
if (input.customerTypes.includes("privatperson")) alternatives.push({ kontaktTyp: "privatperson" });
if (input.customerTypes.includes("organisation")) {
// The superset: any organisation, whatever its legal form.
alternatives.push({ kontaktTyp: "organisation" });
}
if (input.customerTypes.includes("unternehmen")) {
// The commercial subset. Not "everything that is not a public body": an
// association or a foundation is an organisation but not a company.
alternatives.push({ kontaktTyp: "organisation", orgMerkmal: { in: ["gmbh", "kg"] } });
}
if (input.customerTypes.includes("oeffentlich")) {
alternatives.push({ kontaktTyp: "organisation", orgMerkmal: "oeffentliche_einrichtung" });
}
const kontakte = await tx.kontakt.findMany({
where: { tenantId: input.tenantId, archivedAt: null, OR: alternatives },
select: { id: true },
});
return kontakte.map((kontakt) => kontakt.id);
}
function projectListOrderBy(sort: ProjectListSort | undefined): Record<string, "asc" | "desc"> {
switch (sort) {
case "updated_asc":
return { updatedAt: "asc" };
case "name_asc":
return { projektname: "asc" };
case "name_desc":
return { projektname: "desc" };
case "number_asc":
return { projektNummer: "asc" };
case "value_desc":
return { potenziellerAuftragswert: "desc" };
case "updated_desc":
default:
return { updatedAt: "desc" };
}
}
function csvCell(value: string | number | null | undefined): string {
const text = value == null ? "" : String(value);
const safeText = neutralizeSpreadsheetFormula(text);
return `"${safeText.replaceAll('"', '""')}"`;
}
export function validatePipelineStages(stages: readonly Pick<CreatePipelineStageInput, "stageKind" | "name">[]): void {
const active = stages.filter((stage) => normalizeText(stage.name));
if (!active.some((stage) => stage.stageKind === "gewonnen")) {
throw new ProjectServiceError("projects.error.pipelineNeedsWonStage");
}
if (!active.some((stage) => stage.stageKind === "verloren")) {
throw new ProjectServiceError("projects.error.pipelineNeedsLostStage");
}
}
function normalizePipelineStages(stages: readonly CreatePipelineStageInput[]): CreatePipelineStageInput[] {
const normalized = stages
.map((stage, index) => ({
name: normalizeText(stage.name) ?? "",
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? (stage.stageKind === "gewonnen" ? 100 : stage.stageKind === "verloren" ? 0 : 0),
color: normalizeText(stage.color) ?? "#6b7280",
position: stage.position ?? (index + 1) * 10,
}))
.filter((stage) => stage.name);
validatePipelineStages(normalized);
return normalized;
}
export async function createPipeline(input: {
tenantId: string;
userId: string | null;
name: string;
typHint?: PipelineTypHint;
position?: number;
isDefault?: boolean;
isDemoData?: boolean;
stages: readonly CreatePipelineStageInput[];
}): Promise<PipelineRow & { stages: PipelineStageRow[] }> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.pipelineNameRequired");
// S07a create-dialog validation: board names must be unique per tenant
// (among non-archived pipelines) so the admin UI can map this onto the field.
const duplicate = await tx.pipeline.findFirst({
where: { tenantId: input.tenantId, name: { equals: name, mode: "insensitive" }, archivedAt: null },
select: { id: true },
});
if (duplicate) throw new ProjectServiceError("projects.error.pipelineNameDuplicate");
const stages = normalizePipelineStages(input.stages);
const pipeline = await tx.pipeline.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
name,
typHint: input.typHint ?? "custom",
position: input.position ?? 0,
isDefault: input.isDefault ?? false,
isDemoData: input.isDemoData ?? false,
stages: {
create: stages.map((stage) => ({
tenantId: input.tenantId,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? 0,
color: stage.color ?? "#6b7280",
position: stage.position ?? 0,
isDemoData: input.isDemoData ?? false,
})),
},
},
include: { stages: { where: { archivedAt: null }, orderBy: { position: "asc" } } },
});
return pipeline as PipelineRow & { stages: PipelineStageRow[] };
});
}
export async function updatePipeline(input: {
tenantId: string;
pipelineId: string;
name?: string | null;
position?: number | null;
typHint?: PipelineTypHint | null;
/** WF-P8 M04 successor. `undefined` leaves it alone, `null` clears it. */
nachfolgePipelineId?: string | null;
}): Promise<PipelineRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
const data: Record<string, string | number | null> = {};
const name = normalizeText(input.name);
if (input.name !== undefined) {
if (!name) throw new ProjectServiceError("projects.error.pipelineNameRequired");
data.name = name;
}
if (input.position !== undefined && input.position !== null) data.position = input.position;
if (input.typHint) data.typHint = input.typHint;
if (input.nachfolgePipelineId !== undefined) {
const successorId = normalizeText(input.nachfolgePipelineId);
if (successorId) {
// A pipeline handing over to itself would loop the M04 trigger forever.
if (successorId === input.pipelineId) throw new ProjectServiceError("projects.error.successorSelfReference");
const successor = await tx.pipeline.findFirst({
where: { id: successorId, tenantId: input.tenantId, archivedAt: null },
select: { id: true },
});
if (!successor) throw new CatalogError(foundationErrors.notFound, { pipelineId: successorId });
}
data.nachfolgePipelineId = successorId;
}
const row = await tx.pipeline.update({
where: { id: input.pipelineId, tenantId: input.tenantId },
data,
});
return row as PipelineRow;
});
}
export async function archivePipeline(input: { tenantId: string; pipelineId: string }): Promise<PipelineRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
const projects = await tx.projekt.count({ where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null } });
if (projects > 0) throw new ProjectServiceError("projects.error.pipelineStillHasProjects");
const now = new Date();
const row = await tx.pipeline.update({
where: { id: input.pipelineId, tenantId: input.tenantId },
data: { archivedAt: now, stages: { updateMany: { where: { archivedAt: null }, data: { archivedAt: now } } } },
});
return row as PipelineRow;
});
}
export async function duplicatePipeline(input: {
tenantId: string;
userId: string | null;
pipelineId: string;
name: string;
}): Promise<PipelineRow & { stages: PipelineStageRow[] }> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.pipelineNameRequired");
const source = await tx.pipeline.findFirst({
where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null },
include: {
stages: { where: { archivedAt: null }, orderBy: { position: "asc" } },
accessRules: true,
fieldConfigs: { include: { visibilities: true } },
},
});
if (!source) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
validatePipelineStages(source.stages);
const copy = await tx.pipeline.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
name,
typHint: source.typHint,
position: source.position + 1,
isDefault: false,
isDemoData: source.isDemoData,
stages: {
create: source.stages.map((stage) => ({
tenantId: input.tenantId,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability,
color: stage.color,
position: stage.position,
isDemoData: stage.isDemoData,
})),
},
accessRules: {
create: source.accessRules.map((rule) => ({
tenantId: input.tenantId,
principalType: rule.principalType,
principalId: rule.principalId,
kannSehen: rule.kannSehen,
kannBearbeiten: rule.kannBearbeiten,
})),
},
},
include: { stages: { where: { archivedAt: null }, orderBy: { position: "asc" } } },
});
const stageIdByName = new Map(copy.stages.map((stage) => [stage.name, stage.id]));
const sourceStageNameById = new Map(source.stages.map((stage) => [stage.id, stage.name]));
for (const config of source.fieldConfigs) {
const sourceStageName = config.stageId ? sourceStageNameById.get(config.stageId) : null;
const copyStageId = sourceStageName ? stageIdByName.get(sourceStageName) : null;
const createdConfig = await tx.pipelineFieldConfig.create({
data: {
tenantId: input.tenantId,
pipelineId: copy.id,
stageId: copyStageId ?? null,
fieldKey: config.fieldKey,
sichtbar: config.sichtbar,
pflicht: config.pflicht,
reihenfolge: config.reihenfolge,
},
});
if (config.visibilities.length > 0) {
await tx.pipelineFieldVisibility.createMany({
data: config.visibilities.map((visibility) => ({
tenantId: input.tenantId,
fieldConfigId: createdConfig.id,
principalType: visibility.principalType,
principalId: visibility.principalId,
kannSehen: visibility.kannSehen,
kannBearbeiten: visibility.kannBearbeiten,
})),
});
}
}
return copy as PipelineRow & { stages: PipelineStageRow[] };
});
}
export async function upsertPipelineAccess(input: { tenantId: string; pipelineId: string } & PipelineAccessInput): Promise<void> {
return withTenantRLS(input.tenantId, async (tx) => {
await assertPipelineOwnedWithTx(tx, input.tenantId, input.pipelineId);
await assertPipelinePrincipalOwnedWithTx(tx, input);
const principalId = normalizeText(input.principalId)!;
const existing = await tx.pipelineAccess.findFirst({
where: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
principalType: input.principalType,
principalId,
},
select: { id: true },
});
const data = { kannSehen: input.kannSehen ?? true, kannBearbeiten: input.kannBearbeiten ?? false };
if (existing) {
await tx.pipelineAccess.update({ where: { id: existing.id, tenantId: input.tenantId }, data });
return;
}
await tx.pipelineAccess.create({
data: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
principalType: input.principalType,
principalId,
...data,
},
});
});
}
export async function upsertPipelineFieldConfig(input: { tenantId: string; pipelineId: string } & PipelineFieldConfigInput): Promise<void> {
return withTenantRLS(input.tenantId, async (tx) => {
const fieldKey = normalizeText(input.fieldKey);
if (!fieldKey) throw new ProjectServiceError("projects.error.fieldKeyRequired");
const stageId = input.stageId ?? null;
if (stageId) {
await assertPipelineStageOwnedWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId, stageId });
} else {
await assertPipelineOwnedWithTx(tx, input.tenantId, input.pipelineId);
}
const advisoryLockKey = `pipeline-field-config:${input.tenantId}:${input.pipelineId}:${stageId ?? "global"}:${fieldKey}`;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${advisoryLockKey}, 0))`;
const existing = await tx.pipelineFieldConfig.findFirst({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, stageId, fieldKey },
select: { id: true },
});
if (input.groupId) {
// A field can only be filed under a group of its own pipeline.
const group = await tx.pipelineFieldGroup.findFirst({
where: { id: input.groupId, tenantId: input.tenantId, pipelineId: input.pipelineId },
select: { id: true },
});
if (!group) throw new CatalogError(foundationErrors.notFound, { groupId: input.groupId });
}
if (input.pflichtAbStageId) {
await assertPipelineStageOwnedWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId, stageId: input.pflichtAbStageId });
}
const data = {
sichtbar: input.sichtbar ?? true,
pflicht: input.pflicht ?? false,
reihenfolge: input.reihenfolge ?? 0,
// `undefined` = leave untouched (Prisma skips the key), `null` = clear.
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
...(input.pflichtAbStageId !== undefined ? { pflichtAbStageId: input.pflichtAbStageId } : {}),
...(input.hinweis !== undefined ? { hinweis: normalizeText(input.hinweis) } : {}),
...(input.istSystem !== undefined ? { istSystem: input.istSystem } : {}),
...(input.gesperrt !== undefined ? { gesperrt: input.gesperrt } : {}),
};
if (existing) {
await tx.pipelineFieldConfig.update({ where: { id: existing.id, tenantId: input.tenantId }, data });
return;
}
await tx.pipelineFieldConfig.create({
data: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
stageId,
fieldKey,
...data,
},
});
});
}
export async function upsertPipelineFieldVisibility(input: { tenantId: string } & PipelineFieldVisibilityInput): Promise<void> {
return withTenantRLS(input.tenantId, async (tx) => {
const fieldConfig = await tx.pipelineFieldConfig.findFirst({
where: {
id: input.fieldConfigId,
tenantId: input.tenantId,
pipeline: { tenantId: input.tenantId, archivedAt: null },
},
select: { id: true },
});
if (!fieldConfig) throw new CatalogError(foundationErrors.notFound, { fieldConfigId: input.fieldConfigId });
await assertPipelinePrincipalOwnedWithTx(tx, input);
const principalId = normalizeText(input.principalId)!;
const existing = await tx.pipelineFieldVisibility.findFirst({
where: {
tenantId: input.tenantId,
fieldConfigId: fieldConfig.id,
principalType: input.principalType,
principalId,
},
select: { id: true },
});
const data = { kannSehen: input.kannSehen ?? true, kannBearbeiten: input.kannBearbeiten ?? false };
if (existing) {
await tx.pipelineFieldVisibility.update({ where: { id: existing.id, tenantId: input.tenantId }, data });
return;
}
await tx.pipelineFieldVisibility.create({
data: {
tenantId: input.tenantId,
fieldConfigId: fieldConfig.id,
principalType: input.principalType,
principalId,
...data,
},
});
});
}
function projectNumber(prefix: string, nextNumber: number, padding: number): string {
return `${prefix}${String(nextNumber).padStart(padding, "0")}`;
}
async function reserveNumberWithTx(tx: TenantRlsClient, tenantId: string, entityType: "projekt" | "rechnung", prefix: string): Promise<string> {
const sequenceId = `seq_${randomUUID()}`;
await tx.$executeRaw`
INSERT INTO "HiveNumberSeq" ("id", "tenantId", "entityType", "prefix", "nextNumber", "padding", "createdAt", "updatedAt")
VALUES (${sequenceId}, ${tenantId}, ${entityType}, ${prefix}, 1, 5, now(), now())
ON CONFLICT ("tenantId", "entityType") DO NOTHING
`;
const rows = await tx.$queryRaw<Array<{ nextNumber: number; padding: number; prefix: string }>>`
SELECT "nextNumber", "padding", "prefix"
FROM "HiveNumberSeq"
WHERE "tenantId" = ${tenantId} AND "entityType" = ${entityType}
FOR UPDATE
`;
const current = rows[0];
if (!current) throw new ProjectServiceError("projects.error.numberSequenceMissing");
await tx.hiveNumberSeq.update({
where: { tenantId_entityType: { tenantId, entityType } },
data: { nextNumber: current.nextNumber + 1 },
});
return projectNumber(current.prefix || prefix, current.nextNumber, current.padding);
}
async function ensureDefaultLostReasonsWithTx(tx: TenantRlsClient, tenantId: string): Promise<void> {
for (const [index, name] of DEFAULT_SALES_LOST_REASONS.entries()) {
await tx.lostReason.upsert({
where: { tenantId_name: { tenantId, name } },
update: { archivedAt: null, position: (index + 1) * 10, isDemoData: false },
create: { tenantId, name, position: (index + 1) * 10, isDemoData: false },
});
}
}
export async function ensureDefaultSalesPipelineWithTx(
tx: TenantRlsClient,
input: { tenantId: string; userId?: string | null }
): Promise<{
pipeline: PipelineRow;
firstOpenStage: PipelineStageRow;
}> {
validatePipelineStages(DEFAULT_SALES_PIPELINE_STAGES);
const existing = await tx.pipeline.findFirst({
where: { tenantId: input.tenantId, typHint: "sales", isDefault: true, archivedAt: null },
orderBy: { createdAt: "asc" },
});
if (existing) {
const existingOpenStage = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId: existing.id, stageKind: "offen", archivedAt: null },
orderBy: { position: "asc" },
});
if (existingOpenStage) {
return { pipeline: existing as PipelineRow, firstOpenStage: existingOpenStage as PipelineStageRow };
}
}
const pipeline =
existing ??
(await tx.pipeline.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId ?? null,
name: "Sales",
typHint: "sales",
position: 10,
isDefault: true,
isDemoData: false,
},
}));
for (const stage of DEFAULT_SALES_PIPELINE_STAGES) {
await tx.pipelineStage.upsert({
where: { pipelineId_name: { pipelineId: pipeline.id, name: stage.name } },
update: {
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? 0,
color: stage.color ?? "#6b7280",
position: stage.position ?? 0,
archivedAt: null,
isDemoData: false,
},
create: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? 0,
color: stage.color ?? "#6b7280",
position: stage.position ?? 0,
isDemoData: false,
},
});
}
await ensureDefaultLostReasonsWithTx(tx, input.tenantId);
const firstOpenStage = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId: pipeline.id, stageKind: "offen", archivedAt: null },
orderBy: { position: "asc" },
});
if (!firstOpenStage) {
throw new ProjectServiceError("projects.error.pipelineNeedsOpenStage");
}
return { pipeline: pipeline as PipelineRow, firstOpenStage: firstOpenStage as PipelineStageRow };
}
export async function ensureDefaultSalesPipeline(input: { tenantId: string; userId?: string | null }): Promise<{
pipeline: PipelineRow;
firstOpenStage: PipelineStageRow;
}> {
return withTenantRLS(input.tenantId, (tx) => ensureDefaultSalesPipelineWithTx(tx, input));
}
export async function listProjects(input: {
tenantId: string;
ctx?: ProjectVisibilityContext;
limit?: number;
includeDemoData?: boolean;
}): Promise<ProjektRow[]> {
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
return withTenantRLS(input.tenantId, async (tx) => {
const rows = await tx.projekt.findMany({
where: projectListWhere({ tenantId: input.tenantId, ctx: input.ctx, filters: { includeDemoData: input.includeDemoData } }),
orderBy: { updatedAt: "desc" },
take: limit,
});
return rows as ProjektRow[];
});
}
export async function getProject(input: { tenantId: string; projectId: string; ctx?: ProjectVisibilityContext }): Promise<ProjektRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const row = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...(input.ctx ? projectReadScopeWhere(input.tenantId, input.ctx) : {}),
},
});
if (!row) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
return row as ProjektRow;
});
}
export type CreateProjectInput = {
tenantId: string;
userId: string | null;
ctx?: ProjectVisibilityContext;
projektname: string;
beschreibung?: string | null;
projekttyp?: string | null;
quelle?: string | null;
prioritaet?: ProjektPrioritaet;
pipelineId?: string | null;
stageId?: string | null;
potenziellerAuftragswert?: string | number | null;
auftragsvolumen?: string | number | null;
zustaendigePersonId?: string | null;
projektleiterId?: string | null;
konvertiertAusLeadId?: string | null;
kontaktId?: string | null;
kontaktRolle?: ProjektKontaktRolle;
isDemoData?: boolean;
};
async function createProjectWithTx(tx: TenantRlsClient, input: CreateProjectInput): Promise<ProjektRow> {
const name = normalizeText(input.projektname);
if (!name) throw new ProjectServiceError("projects.error.nameRequired");
// Resolve the target board + stage. A caller may pass:
// - both pipelineId and stageId -> use them as-is;
// - pipelineId only (e.g. quick-create on a specific board) -> land in THAT
// board's first open stage, never the default sales board;
// - neither -> fall back to the tenant's default sales pipeline.
let pipelineId = input.pipelineId ?? undefined;
let stageId = input.stageId ?? undefined;
if (pipelineId && !stageId) {
const firstOpenStage = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId, stageKind: "offen", archivedAt: null },
orderBy: { position: "asc" },
});
if (!firstOpenStage) throw new ProjectServiceError("projects.error.pipelineNeedsOpenStage");
stageId = firstOpenStage.id;
} else if (!pipelineId) {
const defaultPipeline = await ensureDefaultSalesPipelineWithTx(tx, { tenantId: input.tenantId, userId: input.userId });
pipelineId = defaultPipeline.pipeline.id;
stageId = stageId ?? defaultPipeline.firstOpenStage.id;
}
if (!pipelineId || !stageId) throw new ProjectServiceError("projects.error.pipelineRequired");
await assertPipelineStageOwnedWithTx(tx, { tenantId: input.tenantId, pipelineId, stageId });
if (input.ctx) {
const editablePipeline = await tx.pipeline.findFirst({
where: {
id: pipelineId,
tenantId: input.tenantId,
archivedAt: null,
...pipelineEditabilityScopeWhere(input.tenantId, input.ctx),
},
select: { id: true },
});
if (!editablePipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId });
}
const createdByUserId = normalizeText(input.userId);
const zustaendigePersonId = normalizeText(input.zustaendigePersonId) ?? createdByUserId;
const projektleiterId = normalizeText(input.projektleiterId);
const kontaktId = normalizeText(input.kontaktId);
const konvertiertAusLeadId = normalizeText(input.konvertiertAusLeadId);
await assertTenantUsersOwnedWithTx(tx, input.tenantId, [
{ userId: createdByUserId, field: "createdByUserId" },
{ userId: zustaendigePersonId, field: "zustaendigePersonId" },
{ userId: projektleiterId, field: "projektleiterId" },
]);
if (input.ctx && (input.potenziellerAuftragswert != null || input.auftragsvolumen != null)) {
const volumeAccess = await resolveProjectVolumeAccessWithTx(
tx,
{
tenantId: input.tenantId,
pipelineId,
stageId,
zustaendigePersonId,
projektleiterId,
},
input.ctx
);
if (!volumeAccess.canEdit) throw new ProjectServiceError("projects.error.volumeNotEditable");
}
if (kontaktId) {
const kontakt = await tx.kontakt.findFirst({
where: { id: kontaktId, tenantId: input.tenantId, archivedAt: null },
select: { id: true },
});
if (!kontakt) throw new CatalogError(foundationErrors.notFound, { kontaktId });
}
if (konvertiertAusLeadId) {
const lead = await tx.inboxLead.findFirst({
where: { id: konvertiertAusLeadId, tenantId: input.tenantId, archivedAt: null },
select: { id: true },
});
if (!lead) throw new CatalogError(foundationErrors.notFound, { leadId: konvertiertAusLeadId });
}
const nummer = await reserveNumberWithTx(tx, input.tenantId, "projekt", "P-");
const project = await tx.projekt.create({
data: {
tenantId: input.tenantId,
createdByUserId,
projektNummer: nummer,
projektname: name,
beschreibung: normalizeText(input.beschreibung),
projekttyp: normalizeText(input.projekttyp),
quelle: normalizeText(input.quelle),
prioritaet: input.prioritaet ?? "normal",
pipelineId,
stageId,
potenziellerAuftragswert: input.potenziellerAuftragswert == null ? undefined : input.potenziellerAuftragswert,
auftragsvolumen: input.auftragsvolumen == null ? undefined : input.auftragsvolumen,
zustaendigePersonId,
projektleiterId,
konvertiertAusLeadId,
isDemoData: input.isDemoData ?? false,
},
});
if (kontaktId) {
await tx.projektKontakt.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
kontaktId,
rolle: input.kontaktRolle ?? "ansprechpartner",
primaer: true,
},
});
}
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_stage_changed",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.created",
metadata: { pipelineId, stageId, source: input.konvertiertAusLeadId ? "lead.qualified" : "manual" },
},
{ client: tx }
);
await publishProjectSearchUpsert(tx, project as ProjektRow);
await ensureAndSyncProjectChannelIfEnabledWithTx(tx, {
tenantId: input.tenantId,
projectId: project.id,
actorUserId: input.userId,
requestId: `project:${project.id}:chat-created`,
});
return project as ProjektRow;
}
export async function createProject(input: CreateProjectInput & { ctx: ProjectVisibilityContext }): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound);
}
const project = await withTenantRLS(input.tenantId, (tx) => createProjectWithTx(tx, input));
await refreshPipelineAggregatesAfterMutation(input.tenantId, [project.pipelineId]);
return project;
}
/**
* Duplicate a project into the same pipeline/stage (board context menu). Copies
* the Stammdaten fields + ProjektKontakt links; lifecycle state (won/lost, undo
* window, archive, lead conversion) and sub-entities (checklists, appointments,
* invoices) are NOT copied. The copy keeps the source's isDemoData flag so demo
* copies stay demo. Gated by `projects.write`; the source must be readable.
*/
export async function duplicateProject(input: {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
}): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const duplicated = await withTenantRLS(input.tenantId, async (tx) => {
const source = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectReadScopeWhere(input.tenantId, input.ctx),
},
});
if (!source) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
// Same number mechanism as createProject: reserve the next tenant-wide
// project number from HiveNumberSeq inside the transaction.
const nummer = await reserveNumberWithTx(tx, input.tenantId, "projekt", "P-");
const project = await tx.projekt.create({
data: {
tenantId: input.tenantId,
createdByUserId: normalizeText(input.userId),
projektNummer: nummer,
projektname: `${source.projektname} (Kopie)`,
beschreibung: source.beschreibung,
projekttyp: source.projekttyp,
quelle: source.quelle,
prioritaet: source.prioritaet,
pipelineId: source.pipelineId,
stageId: source.stageId,
potenziellerAuftragswert: source.potenziellerAuftragswert,
auftragsvolumen: source.auftragsvolumen,
startDatum: source.startDatum,
deadline: source.deadline,
zustaendigePersonId: source.zustaendigePersonId,
projektleiterId: source.projektleiterId,
projektAdresseId: source.projektAdresseId,
rechnungsAdresseId: source.rechnungsAdresseId,
bauherrAdresseId: source.bauherrAdresseId,
isDemoData: source.isDemoData,
},
});
const kontaktLinks = await tx.projektKontakt.findMany({
where: { tenantId: input.tenantId, projektId: source.id },
select: { kontaktId: true, rolle: true, primaer: true },
});
if (kontaktLinks.length > 0) {
await tx.projektKontakt.createMany({
data: kontaktLinks.map((link) => ({
tenantId: input.tenantId,
projektId: project.id,
kontaktId: link.kontaktId,
rolle: link.rolle,
primaer: link.primaer,
})),
});
}
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
// "project_created" is not part of the activity-type union — fall back
// to "project_updated" as agreed in the ticket.
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.duplicated",
metadata: { sourceProjectId: source.id, pipelineId: project.pipelineId, stageId: project.stageId },
},
{ client: tx }
);
await publishProjectSearchUpsert(tx, project as ProjektRow);
return project as ProjektRow;
});
await refreshPipelineAggregatesAfterMutation(input.tenantId, [duplicated.pipelineId]);
return duplicated;
}
export function leadProjectName(lead: { vorname: string | null; nachname: string | null; email: string | null; telefon: string | null }): string {
const name = `${lead.vorname ?? ""} ${lead.nachname ?? ""}`.trim();
return name ? `Projekt ${name}` : `Projekt ${lead.email ?? lead.telefon ?? "aus Lead"}`;
}
function workflowPayload(project: Pick<ProjektRow, "id" | "tenantId" | "pipelineId" | "stageId" | "konvertiertAusLeadId" | "zustaendigePersonId">): {
[key: string]: QueueJsonValue;
} {
return {
projectId: project.id,
tenantId: project.tenantId,
pipelineId: project.pipelineId,
stageId: project.stageId,
konvertiertAusLeadId: project.konvertiertAusLeadId,
zustaendigePersonId: project.zustaendigePersonId,
};
}
export async function createProjectFromQualifiedLead(input: {
tenantId: string;
leadId: string;
userId: string | null;
/** Target board + stage. Omitted => the tenant's default sales pipeline, which
* is what the automatic conversion path still relies on. */
pipelineId?: string | null;
stageId?: string | null;
/** Overrides the derived "Projekt <Name>" default. */
projektname?: string | null;
/** Explicit contact link; falls back to the lead's own link, then to an
* e-mail match. */
kontaktId?: string | null;
}): Promise<ProjektRow> {
const created = await withTenantRLS(input.tenantId, async (tx) => {
const lead = await tx.inboxLead.findFirst({ where: { id: input.leadId, tenantId: input.tenantId, archivedAt: null } });
if (!lead) throw new CatalogError(foundationErrors.notFound, { leadId: input.leadId });
if (lead.konvertiertProjektId) {
const existing = await tx.projekt.findFirst({ where: { id: lead.konvertiertProjektId, tenantId: input.tenantId, archivedAt: null } });
if (existing) {
await ensureAndSyncProjectChannelIfEnabledWithTx(tx, {
tenantId: input.tenantId,
projectId: existing.id,
actorUserId: input.userId,
requestId: `lead:${lead.id}:project:${existing.id}:chat-replay`,
});
return existing as ProjektRow;
}
}
if (lead.status !== "qualified" || lead.weiterverarbeitungFreigabe !== "ja") {
throw new ProjectServiceError("projects.error.leadNotQualified");
}
const explicitKontaktId = normalizeText(input.kontaktId);
const matchedKontakt = explicitKontaktId
? { id: explicitKontaktId }
: lead.verknuepfterKontaktId
? { id: lead.verknuepfterKontaktId }
: lead.email
? await tx.kontakt.findFirst({ where: { tenantId: input.tenantId, archivedAt: null, primaerEmail: lead.email.toLowerCase() }, select: { id: true } })
: null;
const project = await createProjectWithTx(tx, {
tenantId: input.tenantId,
userId: input.userId,
projektname: normalizeText(input.projektname) ?? leadProjectName(lead),
beschreibung: lead.message,
quelle: `lead:${lead.quelle}`,
pipelineId: input.pipelineId ?? null,
stageId: input.stageId ?? null,
zustaendigePersonId: lead.zustaendigerUserId ?? input.userId,
konvertiertAusLeadId: lead.id,
kontaktId: matchedKontakt?.id ?? null,
kontaktRolle: "ansprechpartner",
});
const updatedLead = await tx.inboxLead.update({
where: { id: lead.id },
data: {
status: "converted",
weiterverarbeitungFreigabe: "ja",
konvertiertProjektId: project.id,
verknuepfterKontaktId: matchedKontakt?.id ?? lead.verknuepfterKontaktId,
convertedAt: new Date(),
autoDeleteAt: null,
},
});
await publishLeadSearchUpsert(tx, updatedLead);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "lead.converted",
data: { leadId: lead.id, projectId: project.id, action: "auto_project_created" },
userId: input.userId,
sourceId: `lead:${lead.id}:project:${project.id}`,
})
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.created",
data: workflowPayload(project),
userId: input.userId,
sourceId: `project:${project.id}:created`,
})
);
return project;
});
await refreshPipelineAggregatesAfterMutation(input.tenantId, [created.pipelineId]);
return created;
}
export async function listPipelines(input: { tenantId: string; ctx?: ProjectVisibilityContext }): Promise<Array<PipelineRow & { stages: PipelineStageRow[] }>> {
return withTenantRLS(input.tenantId, async (tx) => {
const rows = await tx.pipeline.findMany({
where: {
tenantId: input.tenantId,
archivedAt: null,
...(input.ctx ? pipelineVisibilityScopeWhere(input.tenantId, input.ctx) : {}),
},
include: { stages: { where: { archivedAt: null }, orderBy: { position: "asc" } } },
orderBy: { position: "asc" },
});
return rows as Array<PipelineRow & { stages: PipelineStageRow[] }>;
});
}
export type PipelineAccessRow = {
id: string;
tenantId: string;
pipelineId: string;
principalType: PipelinePrincipalType;
principalId: string;
kannSehen: boolean;
kannBearbeiten: boolean;
};
export type PipelineFieldVisibilityRow = {
id: string;
tenantId: string;
fieldConfigId: string;
principalType: PipelinePrincipalType;
principalId: string;
kannSehen: boolean;
kannBearbeiten: boolean;
};
export type PipelineFieldConfigRow = {
id: string;
tenantId: string;
pipelineId: string;
stageId: string | null;
fieldKey: string;
sichtbar: boolean;
pflicht: boolean;
reihenfolge: number;
visibilities: PipelineFieldVisibilityRow[];
};
export type PipelineAdminRow = PipelineRow & {
stages: PipelineStageRow[];
accessRules: PipelineAccessRow[];
fieldConfigs: PipelineFieldConfigRow[];
};
export async function listPipelinesForAdmin(input: { tenantId: string }): Promise<PipelineAdminRow[]> {
return withTenantRLS(input.tenantId, async (tx) => {
const rows = await tx.pipeline.findMany({
where: { tenantId: input.tenantId, archivedAt: null },
include: {
stages: { where: { archivedAt: null }, orderBy: { position: "asc" } },
accessRules: true,
fieldConfigs: { include: { visibilities: true }, orderBy: [{ stageId: "asc" }, { reihenfolge: "asc" }] },
},
orderBy: { position: "asc" },
});
return rows as PipelineAdminRow[];
});
}
export type PipelineAdminCounts = {
/** Active projects per board — the S07 "Projekte" column. */
byPipeline: ReadonlyMap<string, number>;
/** Active projects per column — the S04 "Projekte" cell of a column row. */
byStage: ReadonlyMap<string, number>;
};
/**
* WF-P8 S07/S04: project counts for the board admin. Two grouped queries for the
* whole area instead of one per row — both tables render a count in every row.
* Archived projects are left out; the count is meant to answer "what is in this
* board right now", which is also what makes a delete warning trustworthy.
*/
export async function loadPipelineAdminCounts(input: { tenantId: string }): Promise<PipelineAdminCounts> {
return withTenantRLS(input.tenantId, async (tx) => {
const [perPipeline, perStage] = await Promise.all([
tx.projekt.groupBy({ by: ["pipelineId"], where: { tenantId: input.tenantId, archivedAt: null }, _count: { _all: true } }),
tx.projekt.groupBy({ by: ["stageId"], where: { tenantId: input.tenantId, archivedAt: null }, _count: { _all: true } }),
]);
return {
byPipeline: new Map(perPipeline.map((row) => [row.pipelineId, row._count._all])),
byStage: new Map(perStage.map((row) => [row.stageId, row._count._all])),
};
});
}
export async function listLostReasons(input: { tenantId: string }): Promise<LostReasonRow[]> {
return withTenantRLS(input.tenantId, async (tx) => {
return (await tx.lostReason.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: { position: "asc" } })) as LostReasonRow[];
});
}
const LOSS_WINDOW_DAYS = 30;
/**
* WF-P8 S06: every reason including the archived ones, plus the two numbers the
* admin table needs. Archived reasons stay in the list on purpose — the screen
* filters them, and hiding them here would make "Reaktivieren" unreachable.
*
* Three aggregate queries for the whole table rather than two per row.
*/
export async function listLostReasonsForAdmin(input: { tenantId: string; now?: Date }): Promise<{
rows: LostReasonAdminRow[];
insights: LostReasonInsights;
}> {
const now = input.now ?? new Date();
const windowStart = new Date(now.getTime() - LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
const previousStart = new Date(now.getTime() - 2 * LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
return withTenantRLS(input.tenantId, async (tx) => {
const [reasons, projectTotals, lastUsed, recentByReason, previousCount] = await Promise.all([
tx.lostReason.findMany({ where: { tenantId: input.tenantId }, orderBy: [{ archivedAt: "asc" }, { position: "asc" }] }),
tx.projekt.groupBy({ by: ["lostReasonId"], where: { tenantId: input.tenantId, lostReasonId: { not: null } }, _count: { _all: true } }),
tx.projektStageChange.groupBy({
by: ["lostReasonId"],
where: { tenantId: input.tenantId, lostReasonId: { not: null }, undoneAt: null },
_max: { createdAt: true },
}),
tx.projektStageChange.groupBy({
by: ["lostReasonId"],
where: { tenantId: input.tenantId, lostReasonId: { not: null }, undoneAt: null, createdAt: { gte: windowStart } },
_count: { _all: true },
}),
tx.projektStageChange.count({
where: {
tenantId: input.tenantId,
lostReasonId: { not: null },
undoneAt: null,
createdAt: { gte: previousStart, lt: windowStart },
},
}),
]);
const countsByReason = new Map(projectTotals.map((total) => [total.lostReasonId, total._count._all]));
const lastUsedByReason = new Map(lastUsed.map((entry) => [entry.lostReasonId, entry._max.createdAt]));
const nameById = new Map(reasons.map((reason) => [reason.id, reason.name]));
const topRecent = [...recentByReason].sort((left, right) => right._count._all - left._count._all)[0];
return {
rows: reasons.map((reason) => ({
...(reason as LostReasonRow),
projectCount: countsByReason.get(reason.id) ?? 0,
lastUsedAt: lastUsedByReason.get(reason.id) ?? null,
})),
insights: {
topReasonName: topRecent?.lostReasonId ? (nameById.get(topRecent.lostReasonId) ?? null) : null,
recentLossCount: recentByReason.reduce((sum, entry) => sum + entry._count._all, 0),
previousLossCount: previousCount,
},
};
});
}
/** WF-P8 S06 "Reaktivieren": bring an archived reason back into the picker. */
export async function reactivateLostReason(input: { tenantId: string; reasonId: string }): Promise<LostReasonRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.lostReason.findFirst({
where: { id: input.reasonId, tenantId: input.tenantId, archivedAt: { not: null } },
select: { id: true },
});
if (!existing) throw new CatalogError(foundationErrors.notFound, { reasonId: input.reasonId });
return (await tx.lostReason.update({ where: { id: input.reasonId }, data: { archivedAt: null } })) as LostReasonRow;
});
}
export async function upsertLostReason(input: {
tenantId: string;
reasonId?: string | null;
name: string;
beschreibung?: string | null;
kategorie?: string | null;
position?: number | null;
isDemoData?: boolean;
}): Promise<LostReasonRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.lostReasonNameRequired");
if (input.reasonId) {
const existing = await tx.lostReason.findFirst({ where: { id: input.reasonId, tenantId: input.tenantId }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { reasonId: input.reasonId });
const row = await tx.lostReason.update({
where: { id: input.reasonId },
data: {
name,
beschreibung: normalizeText(input.beschreibung),
kategorie: normalizeText(input.kategorie),
position: input.position ?? 0,
archivedAt: null,
},
});
return row as LostReasonRow;
}
const row = await tx.lostReason.upsert({
where: { tenantId_name: { tenantId: input.tenantId, name } },
update: {
beschreibung: normalizeText(input.beschreibung),
kategorie: normalizeText(input.kategorie),
position: input.position ?? 0,
archivedAt: null,
},
create: {
tenantId: input.tenantId,
name,
beschreibung: normalizeText(input.beschreibung),
kategorie: normalizeText(input.kategorie),
position: input.position ?? 0,
isDemoData: input.isDemoData ?? false,
},
});
return row as LostReasonRow;
});
}
export async function archiveLostReason(input: { tenantId: string; reasonId: string }): Promise<LostReasonRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.lostReason.findFirst({ where: { id: input.reasonId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { reasonId: input.reasonId });
const row = await tx.lostReason.update({
where: { id: input.reasonId },
data: { archivedAt: new Date() },
});
return row as LostReasonRow;
});
}
async function assertPipelineReadableWithTx(
tx: TenantRlsClient,
input: { tenantId: string; pipelineId: string; ctx?: ProjectVisibilityContext }
): Promise<PipelineRow> {
const pipeline = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null } });
if (!pipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
if (input.ctx) {
const deniedAccess = await tx.pipelineAccess.findFirst({
where: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
kannSehen: false,
OR: effectivePipelinePrincipals(input.ctx),
},
select: { id: true },
});
if (deniedAccess) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
}
return pipeline as PipelineRow;
}
function explicitPrincipalDecision<K extends "kannSehen" | "kannBearbeiten">(visibilities: ReadonlyArray<Record<K, boolean>>, key: K): boolean | undefined {
if (visibilities.some((visibility) => !visibility[key])) return false;
if (visibilities.some((visibility) => visibility[key])) return true;
return undefined;
}
function explicitPrincipalVisibility(visibilities: readonly { kannSehen: boolean }[]): boolean | undefined {
return explicitPrincipalDecision(visibilities, "kannSehen");
}
type EffectiveFieldConfig = {
fieldKey: string;
stageId: string | null;
sichtbar: boolean;
visibilities: Array<{ kannSehen: boolean; kannBearbeiten: boolean }>;
};
function selectEffectiveFieldConfigs(configs: readonly EffectiveFieldConfig[], fieldKey: string, stageId: string | null | undefined): EffectiveFieldConfig[] {
const exact = configs.filter((config) => config.fieldKey === fieldKey && config.stageId === (stageId ?? null));
return exact.length > 0 ? exact : configs.filter((config) => config.fieldKey === fieldKey && config.stageId === null);
}
type ProjectVolumeAccess = { canView: boolean; canEdit: boolean };
const PROJECT_VOLUME_FIELD_KEYS = ["auftragsvolumen", "potenziellerAuftragswert"] as const;
async function resolveProjectVolumeAccessWithTx(
tx: TenantRlsClient,
project: Pick<ProjektRow, "tenantId" | "pipelineId" | "stageId" | "zustaendigePersonId" | "projektleiterId">,
ctx: ProjectVisibilityContext | undefined
): Promise<ProjectVolumeAccess> {
if (!ctx) return { canView: false, canEdit: false };
const configs = await tx.pipelineFieldConfig.findMany({
where: {
tenantId: project.tenantId,
pipelineId: project.pipelineId,
fieldKey: { in: [...PROJECT_VOLUME_FIELD_KEYS] },
OR: [{ stageId: project.stageId }, { stageId: null }],
},
include: {
visibilities: {
where: { tenantId: project.tenantId, OR: effectivePipelinePrincipals(ctx) },
},
},
orderBy: { reihenfolge: "asc" },
});
const permissionBackstop = hasPermission(ctx, "deals.read_volume_all") || (hasPermission(ctx, "deals.read_volume_own") && isProjectOwner(project, ctx));
const decisions = PROJECT_VOLUME_FIELD_KEYS.map((fieldKey) => {
const effectiveConfigs = selectEffectiveFieldConfigs(configs as EffectiveFieldConfig[], fieldKey, project.stageId);
const visibilities = effectiveConfigs.flatMap((config) => config.visibilities);
const explicitView = explicitPrincipalDecision(visibilities, "kannSehen");
const canView = explicitView ?? (effectiveConfigs.some((config) => !config.sichtbar) ? false : permissionBackstop);
const explicitEdit = explicitPrincipalDecision(visibilities, "kannBearbeiten");
return { canView, canEdit: canView && (explicitEdit ?? true) };
});
return {
canView: decisions.every((decision) => decision.canView),
canEdit: decisions.every((decision) => decision.canEdit),
};
}
async function canViewProjectVolumeWithTx(
tx: TenantRlsClient,
project: Pick<ProjektRow, "tenantId" | "pipelineId" | "stageId" | "zustaendigePersonId" | "projektleiterId">,
ctx: ProjectVisibilityContext | undefined
): Promise<boolean> {
return (await resolveProjectVolumeAccessWithTx(tx, project, ctx)).canView;
}
type ProjectVolumeVisibilityRule = {
fieldKey: string;
sichtbar: boolean;
explicit: boolean | undefined;
};
type ProjectVolumeVisibilityPolicy = Map<string, ProjectVolumeVisibilityRule>;
function projectVolumeVisibilityKey(pipelineId: string, stageId: string | null, fieldKey: string): string {
return `${pipelineId}:${stageId ?? "*"}:${fieldKey}`;
}
async function loadProjectVolumeVisibilityPolicyWithTx(
tx: TenantRlsClient,
input: {
tenantId: string;
ctx: ProjectVisibilityContext;
projects: ReadonlyArray<Pick<ProjektRow, "pipelineId" | "stageId">>;
}
): Promise<ProjectVolumeVisibilityPolicy> {
const pipelineIds = [...new Set(input.projects.map((project) => project.pipelineId))];
if (pipelineIds.length === 0) return new Map();
const stageIds = [...new Set(input.projects.map((project) => project.stageId))];
const configs = await tx.pipelineFieldConfig.findMany({
where: {
tenantId: input.tenantId,
pipelineId: { in: pipelineIds },
fieldKey: { in: [...PROJECT_VOLUME_FIELD_KEYS] },
OR: [{ stageId: { in: stageIds } }, { stageId: null }],
},
include: {
visibilities: {
where: { tenantId: input.tenantId, OR: effectivePipelinePrincipals(input.ctx) },
},
},
orderBy: { reihenfolge: "asc" },
});
const policy: ProjectVolumeVisibilityPolicy = new Map();
for (const config of configs) {
const key = projectVolumeVisibilityKey(config.pipelineId, config.stageId, config.fieldKey);
const previous = policy.get(key);
const nextExplicit = explicitPrincipalVisibility(config.visibilities);
policy.set(key, {
fieldKey: config.fieldKey,
sichtbar: (previous?.sichtbar ?? true) && config.sichtbar,
explicit: previous?.explicit === false || nextExplicit === false ? false : previous?.explicit === true || nextExplicit === true ? true : undefined,
});
}
return policy;
}
function canViewProjectVolumeFromPolicy(
project: Pick<ProjektRow, "pipelineId" | "stageId" | "zustaendigePersonId" | "projektleiterId">,
ctx: ProjectVisibilityContext,
policy: ProjectVolumeVisibilityPolicy
): boolean {
const permissionBackstop = hasPermission(ctx, "deals.read_volume_all") || (hasPermission(ctx, "deals.read_volume_own") && isProjectOwner(project, ctx));
return PROJECT_VOLUME_FIELD_KEYS.every((fieldKey) => {
const rule =
policy.get(projectVolumeVisibilityKey(project.pipelineId, project.stageId, fieldKey)) ??
policy.get(projectVolumeVisibilityKey(project.pipelineId, null, fieldKey));
if (rule?.explicit !== undefined) return rule.explicit;
if (rule && !rule.sichtbar) return false;
return permissionBackstop;
});
}
async function canViewAggregatesWithTx(tx: TenantRlsClient, input: { tenantId: string; pipelineId: string; ctx?: ProjectVisibilityContext }): Promise<boolean> {
const readablePipelineIds = await filterAggregateReadablePipelineIdsWithTx(tx, {
tenantId: input.tenantId,
pipelineIds: [input.pipelineId],
ctx: input.ctx,
});
return readablePipelineIds.length === 1;
}
/**
* Resolve aggregate-forecast visibility for several pipelines in one query.
* Explicit principal denies win across duplicate configuration rows, which
* keeps reads fail-closed even if legacy nullable-stage rows drifted.
*/
export async function filterAggregateReadablePipelineIdsWithTx(
tx: TenantRlsClient,
input: {
tenantId: string;
pipelineIds: readonly string[];
ctx?: ProjectVisibilityContext;
backstopPermissions?: readonly string[];
}
): Promise<string[]> {
const pipelineIds = [...new Set(input.pipelineIds.filter(Boolean))];
if (!input.ctx || pipelineIds.length === 0) return [];
const configs = await tx.pipelineFieldConfig.findMany({
where: {
tenantId: input.tenantId,
pipelineId: { in: pipelineIds },
fieldKey: "aggregate:forecast",
stageId: null,
},
include: {
visibilities: {
where: { tenantId: input.tenantId, OR: effectivePipelinePrincipals(input.ctx) },
},
},
orderBy: { reihenfolge: "asc" },
});
const backstopPermissions = input.backstopPermissions ?? ["deals.read_aggregates"];
return pipelineIds.filter((pipelineId) => {
const pipelineConfigs = configs.filter((config) => config.pipelineId === pipelineId);
const explicit = explicitPrincipalVisibility(pipelineConfigs.flatMap((config) => config.visibilities));
if (explicit !== undefined) return explicit;
if (pipelineConfigs.some((config) => !config.sichtbar)) return false;
return backstopPermissions.some((permission) => hasPermission(input.ctx, permission));
});
}
/** Per-card companion data resolved in batch (see loadBoardCardCompanionsWithTx). */
type BoardCardCompanions = {
kontaktLabel: string | null;
ort: string | null;
nextTermin: { titel: string; startAt: Date } | null;
tags: { id: string; name: string; farbe: string }[];
};
function toBoardCard(
project: ProjektRow,
valueVisible: boolean,
now: Date,
inStageSince: Date,
companions: BoardCardCompanions
): ProjectBoardCard {
const valueSource = project.auftragsvolumen ?? project.potenziellerAuftragswert;
return {
id: project.id,
projektNummer: project.projektNummer,
projektname: project.projektname,
prioritaet: project.prioritaet,
value: valueVisible ? decimalToString(valueSource) : null,
valueVisible,
ownerUserId: project.zustaendigePersonId,
projektleiterId: project.projektleiterId,
deadline: project.deadline?.toISOString() ?? null,
undoStageChangeUntil: project.undoStageChangeUntil && project.undoStageChangeUntil > now ? project.undoStageChangeUntil.toISOString() : null,
redoStageId: project.redoStageId,
isStuck: Boolean(project.deadline && project.deadline < now),
inStageSince: inStageSince.toISOString(),
kontaktLabel: companions.kontaktLabel,
ort: companions.ort,
projekttyp: normalizeText(project.projekttyp),
startDatum: project.startDatum?.toISOString() ?? null,
nextTerminTitel: companions.nextTermin?.titel ?? null,
nextTerminStartAt: companions.nextTermin?.startAt.toISOString() ?? null,
tags: companions.tags,
};
}
/**
* Batch-resolve the WF-P8 S01 card companions: the primary contact, the city of
* the project address and the next upcoming appointment.
*
* Four fixed queries for the whole board, never one per card — a Kanban board
* shows up to 100 cards per column, so a per-card lookup would be the classic
* N+1. Everything runs inside the caller's RLS transaction, so a card can only
* ever pick up companions from the bound tenant.
*/
async function loadBoardCardCompanionsWithTx(
tx: TenantRlsClient,
input: { tenantId: string; projects: ReadonlyArray<Pick<ProjektRow, "id" | "projektAdresseId">>; now: Date }
): Promise<Map<string, BoardCardCompanions>> {
const empty: BoardCardCompanions = { kontaktLabel: null, ort: null, nextTermin: null, tags: [] };
const result = new Map<string, BoardCardCompanions>(input.projects.map((project) => [project.id, empty]));
const projectIds = input.projects.map((project) => project.id);
if (projectIds.length === 0) return result;
const addressIds = [...new Set(input.projects.map((project) => project.projektAdresseId).filter((id): id is string => Boolean(id)))];
const [links, addresses, termine, tagAssignments] = await Promise.all([
tx.projektKontakt.findMany({
where: { tenantId: input.tenantId, projektId: { in: projectIds } },
// Primary link first, then the oldest one — the card shows exactly one
// contact and must pick the same one on every render.
orderBy: [{ primaer: "desc" }, { createdAt: "asc" }],
select: { projektId: true, kontaktId: true },
}),
addressIds.length
? tx.adresse.findMany({ where: { tenantId: input.tenantId, id: { in: addressIds } }, select: { id: true, ort: true } })
: Promise.resolve([] as { id: string; ort: string | null }[]),
tx.projektTermin.findMany({
where: { tenantId: input.tenantId, projektId: { in: projectIds }, archivedAt: null, startAt: { gte: input.now } },
orderBy: { startAt: "asc" },
select: { projektId: true, titel: true, startAt: true },
}),
// Tags are the shared CRM `Tag`/`TagAssignment` pair (entityType "projekt"),
// not a project-owned table — one query for the whole board.
tx.tagAssignment.findMany({
where: { tenantId: input.tenantId, entityType: "projekt", entityId: { in: projectIds } },
orderBy: { createdAt: "asc" },
select: { entityId: true, tag: { select: { id: true, name: true, farbe: true } } },
}),
]);
const firstKontaktIdByProject = new Map<string, string>();
for (const link of links) {
if (!firstKontaktIdByProject.has(link.projektId)) firstKontaktIdByProject.set(link.projektId, link.kontaktId);
}
const kontaktIds = [...new Set(firstKontaktIdByProject.values())];
const kontakte = kontaktIds.length
? await tx.kontakt.findMany({
where: { tenantId: input.tenantId, id: { in: kontaktIds } },
select: { id: true, vorname: true, nachname: true, orgName: true, primaerEmail: true },
})
: [];
const kontaktLabelById = new Map(kontakte.map((kontakt) => [kontakt.id, kontaktDisplayLabel(kontakt)]));
const ortByAddressId = new Map(addresses.map((address) => [address.id, normalizeText(address.ort)]));
const tagsByProject = new Map<string, { id: string; name: string; farbe: string }[]>();
for (const assignment of tagAssignments) {
const list = tagsByProject.get(assignment.entityId) ?? [];
list.push(assignment.tag);
tagsByProject.set(assignment.entityId, list);
}
const nextTerminByProject = new Map<string, { titel: string; startAt: Date }>();
for (const termin of termine) {
if (!nextTerminByProject.has(termin.projektId)) nextTerminByProject.set(termin.projektId, { titel: termin.titel, startAt: termin.startAt });
}
for (const project of input.projects) {
const kontaktId = firstKontaktIdByProject.get(project.id);
result.set(project.id, {
kontaktLabel: (kontaktId ? kontaktLabelById.get(kontaktId) : null) || null,
ort: project.projektAdresseId ? (ortByAddressId.get(project.projektAdresseId) ?? null) : null,
nextTermin: nextTerminByProject.get(project.id) ?? null,
tags: tagsByProject.get(project.id) ?? [],
});
}
return result;
}
/**
* Batch-resolve when each board project entered its current stage: one query for
* the latest non-undone stage change per project (no N+1). A project whose latest
* change does not target its current stage (or that never changed stage) falls
* back to its createdAt.
*/
async function loadInStageSinceWithTx(
tx: TenantRlsClient,
input: { tenantId: string; projects: ReadonlyArray<Pick<ProjektRow, "id" | "stageId" | "createdAt">> }
): Promise<Map<string, Date>> {
const result = new Map<string, Date>(input.projects.map((project) => [project.id, project.createdAt]));
const projectIds = input.projects.map((project) => project.id);
if (projectIds.length === 0) return result;
const stageIdByProjectId = new Map(input.projects.map((project) => [project.id, project.stageId]));
// `distinct` + `orderBy createdAt desc` yields the newest non-undone change per
// project in a single round-trip (uses the [tenantId, projektId, createdAt] index).
const latestChanges = await tx.projektStageChange.findMany({
where: { tenantId: input.tenantId, projektId: { in: projectIds }, undoneAt: null },
orderBy: { createdAt: "desc" },
distinct: ["projektId"],
select: { projektId: true, toStageId: true, createdAt: true },
});
for (const change of latestChanges) {
if (change.toStageId === stageIdByProjectId.get(change.projektId)) {
result.set(change.projektId, change.createdAt);
}
}
return result;
}
export async function loadProjectBoard(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
pipelineId?: string | null;
onlyMine?: boolean;
includeDemoData?: boolean;
q?: string | null;
cardsPerStage?: number;
/** WF-P8 S01 filter facets (assignee, customer type, period, value band). */
facets?: ProjectFacetFilters;
}): Promise<ProjectBoardVM> {
const cardsPerStage = Math.min(Math.max(input.cardsPerStage ?? 50, 1), 100);
return withTenantRLS(input.tenantId, async (tx) => {
const fallback = input.pipelineId
? null
: await tx.pipeline.findFirst({
where: {
tenantId: input.tenantId,
archivedAt: null,
...pipelineVisibilityScopeWhere(input.tenantId, input.ctx),
},
orderBy: [{ isDefault: "desc" }, { position: "asc" }, { createdAt: "asc" }],
select: { id: true },
});
const pipelineId = input.pipelineId ?? fallback?.id;
if (!pipelineId) throw new ProjectServiceError("projects.error.pipelineRequired");
const pipeline = await assertPipelineReadableWithTx(tx, { tenantId: input.tenantId, pipelineId, ctx: input.ctx });
const [pipelines, stages, aggregateVisible] = await Promise.all([
tx.pipeline.findMany({
where: { tenantId: input.tenantId, archivedAt: null, ...pipelineVisibilityScopeWhere(input.tenantId, input.ctx) },
orderBy: { position: "asc" },
}),
tx.pipelineStage.findMany({ where: { tenantId: input.tenantId, pipelineId, archivedAt: null }, orderBy: { position: "asc" } }),
canViewAggregatesWithTx(tx, { tenantId: input.tenantId, pipelineId, ctx: input.ctx }),
]);
const now = new Date();
const columns: ProjectBoardColumn[] = [];
const [customerTypeKontaktIds, tagProjektIds] = await Promise.all([
resolveCustomerTypeKontaktIdsWithTx(tx, {
tenantId: input.tenantId,
customerTypes: input.facets?.customerTypes ?? [],
}),
resolveTagProjektIdsWithTx(tx, { tenantId: input.tenantId, tagIds: input.facets?.tagIds ?? [] }),
]);
const boardWhere = projectListWhere({
tenantId: input.tenantId,
ctx: input.ctx,
filters: {
pipelineId,
onlyMine: input.onlyMine,
includeDemoData: input.includeDemoData,
q: input.q,
facets: input.facets,
facetNow: now,
customerTypeKontaktIds,
tagProjektIds,
},
});
const [cardsByStage, stageTotals, potentialFallbackTotals] = await Promise.all([
Promise.all(
stages.map(async (stage) => {
const projects = await tx.projekt.findMany({
where: { AND: [boardWhere, { stageId: stage.id }] },
orderBy: { updatedAt: "desc" },
take: cardsPerStage,
});
return [stage.id, projects as ProjektRow[]] as const;
})
),
tx.projekt.groupBy({
by: ["stageId"],
where: boardWhere,
_count: { _all: true },
_sum: { auftragsvolumen: true },
}),
tx.projekt.groupBy({
by: ["stageId"],
where: { AND: [boardWhere, { auftragsvolumen: null }] },
_sum: { potenziellerAuftragswert: true },
}),
]);
const projectsByStage = new Map(cardsByStage);
const projectCountsByStage = new Map(stageTotals.map((total) => [total.stageId, total._count._all]));
const aggregateValuesByStage = new Map(
stageTotals.map((total) => [
total.stageId,
decimalToNumber(total._sum.auftragsvolumen) +
decimalToNumber(potentialFallbackTotals.find((fallback) => fallback.stageId === total.stageId)?._sum.potenziellerAuftragswert),
])
);
const boardProjects = [...projectsByStage.values()].flat();
const [volumePolicy, inStageSinceByProjectId, companionsByProjectId] = await Promise.all([
loadProjectVolumeVisibilityPolicyWithTx(tx, {
tenantId: input.tenantId,
ctx: input.ctx,
projects: boardProjects,
}),
loadInStageSinceWithTx(tx, { tenantId: input.tenantId, projects: boardProjects }),
loadBoardCardCompanionsWithTx(tx, { tenantId: input.tenantId, projects: boardProjects, now }),
]);
for (const stage of stages) {
const cards: ProjectBoardCard[] = [];
for (const project of projectsByStage.get(stage.id) ?? []) {
const canViewVolume = canViewProjectVolumeFromPolicy(project, input.ctx, volumePolicy);
cards.push(
toBoardCard(project, canViewVolume, now, inStageSinceByProjectId.get(project.id) ?? project.createdAt, {
kontaktLabel: companionsByProjectId.get(project.id)?.kontaktLabel ?? null,
ort: companionsByProjectId.get(project.id)?.ort ?? null,
nextTermin: companionsByProjectId.get(project.id)?.nextTermin ?? null,
tags: companionsByProjectId.get(project.id)?.tags ?? [],
})
);
}
columns.push({
stage: stage as PipelineStageRow,
projectCount: projectCountsByStage.get(stage.id) ?? 0,
aggregateValue: aggregateVisible ? (aggregateValuesByStage.get(stage.id) ?? 0).toFixed(2) : null,
aggregateVisible,
cards,
});
}
return {
pipeline: pipeline as PipelineRow,
pipelines: pipelines as PipelineRow[],
columns,
generatedAt: now.toISOString(),
};
});
}
export type BoardAssigneeOption = { userId: string; label: string };
export type BoardAssigneeOptions = { options: BoardAssigneeOption[]; hasUnassigned: boolean };
/**
* Options of the WF-P8 S01 "Sachbearbeiter" facet: the people who actually
* carry a project on this board, not every tenant member.
*
* Deliberately built from the board's *unfiltered* population — otherwise
* selecting one assignee would remove all the others from the dropdown and
* there would be no way back to a second one. Without a pipeline it spans every
* visible board, which is what the cross-pipeline list (S03) needs.
*/
export async function loadBoardAssigneeOptions(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
pipelineId?: string | null;
includeDemoData?: boolean;
}): Promise<BoardAssigneeOptions> {
const grouped = await withTenantRLS(input.tenantId, (tx) =>
tx.projekt.groupBy({
by: ["zustaendigePersonId"],
where: projectListWhere({
tenantId: input.tenantId,
ctx: input.ctx,
filters: { pipelineId: input.pipelineId, includeDemoData: input.includeDemoData },
}),
})
);
const userIds = grouped.map((row) => row.zustaendigePersonId).filter((id): id is string => Boolean(id));
const labels = await resolveUserLabels(userIds);
const options = userIds
.map((userId) => ({ userId, label: labels.get(userId) ?? userId }))
.sort((left, right) => left.label.localeCompare(right.label));
return { options, hasUnassigned: grouped.some((row) => row.zustaendigePersonId == null) };
}
/**
* Options of the WF-P8 S01 "Tags" facet: the tenant tag catalogue.
*
* The whole catalogue, not only the tags currently in use — the facet is also
* how somebody checks which tag has no project yet, and a tag disappearing from
* the list the moment its last project moves would be a filter that quietly
* changes shape.
*/
export async function loadProjectTagOptions(tenantId: string): Promise<{ id: string; name: string; farbe: string }[]> {
const tags = await withTenantRLS(tenantId, (tx) => tx.tag.findMany({ where: { tenantId }, select: { id: true, name: true, farbe: true } }));
return tags.sort((left, right) => left.name.localeCompare(right.name));
}
type ProjectListDbRow = Pick<
ProjektRow,
| "id"
| "projektNummer"
| "projektname"
| "pipelineId"
| "stageId"
| "potenziellerAuftragswert"
| "auftragsvolumen"
| "zustaendigePersonId"
| "projektleiterId"
| "updatedAt"
| "projektAdresseId"
| "projekttyp"
> & {
pipeline: Pick<PipelineRow, "name">;
stage: Pick<PipelineStageRow, "name">;
};
const PROJECT_LIST_SELECT = {
id: true,
projektNummer: true,
projektname: true,
pipelineId: true,
stageId: true,
potenziellerAuftragswert: true,
auftragsvolumen: true,
zustaendigePersonId: true,
projektleiterId: true,
updatedAt: true,
// WF-P8 S03 columns "Kunde", "Termin / Hinweis": the list shows the same
// companions as the board card, so both views describe a project the same way.
projektAdresseId: true,
projekttyp: true,
pipeline: { select: { name: true } },
stage: { select: { name: true } },
} as const;
export const PROJECT_CSV_EXPORT_MAX_ROWS = 10_000;
export function normalizeProjectListPage(value: unknown): number {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isSafeInteger(parsed) && parsed >= 1 ? Math.min(parsed, 10_000) : 1;
}
/**
* @param input.withCompanions resolve the WF-P8 S03 companion columns (customer,
* next appointment). Off by default: the CSV export renders none of them and
* would otherwise pay three extra queries for up to 10k rows.
*/
async function buildProjectListRowsWithTx(
tx: TenantRlsClient,
input: {
tenantId: string;
ctx: ProjectVisibilityContext;
projects: readonly ProjectListDbRow[];
withCompanions?: boolean;
now?: Date;
}
): Promise<ProjectListRow[]> {
const now = input.now ?? new Date();
// Both run inside the caller's RLS transaction, so a row can only ever pick
// up companions from the bound tenant. The companion loader is the same batch
// the board uses - a per-row lookup here would be the classic N+1 on a page
// of up to 200 projects.
const [volumePolicy, companions] = await Promise.all([
loadProjectVolumeVisibilityPolicyWithTx(tx, {
tenantId: input.tenantId,
ctx: input.ctx,
projects: input.projects,
}),
input.withCompanions
? loadBoardCardCompanionsWithTx(tx, {
tenantId: input.tenantId,
projects: input.projects.map((project) => ({ id: project.id, projektAdresseId: project.projektAdresseId })),
now,
})
: Promise.resolve(new Map<string, BoardCardCompanions>()),
]);
return input.projects.map((project) => {
const valueVisible = canViewProjectVolumeFromPolicy(project, input.ctx, volumePolicy);
const companion = companions.get(project.id);
return {
id: project.id,
projektNummer: project.projektNummer,
projektname: project.projektname,
pipelineName: project.pipeline.name,
stageName: project.stage.name,
value: valueVisible ? decimalToString(project.auftragsvolumen ?? project.potenziellerAuftragswert) : null,
valueVisible,
ownerUserId: project.zustaendigePersonId,
projektleiterId: project.projektleiterId,
updatedAt: project.updatedAt.toISOString(),
kontaktLabel: companion?.kontaktLabel ?? null,
ort: companion?.ort ?? null,
projekttyp: normalizeText(project.projekttyp),
nextTerminTitel: companion?.nextTermin?.titel ?? null,
nextTerminStartAt: companion?.nextTermin?.startAt.toISOString() ?? null,
tags: companion?.tags ?? [],
};
});
}
/**
* Fills in the two parts of a facet filter that need the database: the contacts
* behind the customer-type facet and the clock the period ranges are anchored
* on. A no-op when no facet is selected, so the plain list keeps its query count.
*/
async function resolveFacetFiltersWithTx(
tx: TenantRlsClient,
tenantId: string,
filters: ProjectListFilters | undefined
): Promise<ProjectListFilters | undefined> {
if (!filters?.facets) return filters;
const [customerTypeKontaktIds, tagProjektIds] = await Promise.all([
resolveCustomerTypeKontaktIdsWithTx(tx, {
tenantId,
customerTypes: filters.facets.customerTypes ?? [],
}),
resolveTagProjektIdsWithTx(tx, { tenantId, tagIds: filters.facets.tagIds ?? [] }),
]);
return { ...filters, facetNow: filters.facetNow ?? new Date(), customerTypeKontaktIds, tagProjektIds };
}
export async function listProjectsForTable(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
filters?: ProjectListFilters;
sort?: ProjectListSort;
page?: number;
pageSize?: number;
}): Promise<ProjectListResult> {
const page = normalizeProjectListPage(input.page ?? 1);
const pageSize = Math.min(Math.max(input.pageSize ?? 50, 1), 200);
return withTenantRLS(input.tenantId, async (tx) => {
const filters = await resolveFacetFiltersWithTx(tx, input.tenantId, input.filters);
const where = projectListWhere({ tenantId: input.tenantId, ctx: input.ctx, filters });
const [projects, total] = await Promise.all([
tx.projekt.findMany({
where,
select: PROJECT_LIST_SELECT,
orderBy: projectListOrderBy(input.sort),
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.projekt.count({ where }),
]);
const rows = await buildProjectListRowsWithTx(tx, {
tenantId: input.tenantId,
ctx: input.ctx,
projects: projects as ProjectListDbRow[],
withCompanions: true,
});
return { rows, total, page, pageSize };
});
}
export async function exportProjectListCsv(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
filters?: ProjectListFilters;
sort?: ProjectListSort;
}): Promise<string> {
const rows = await withTenantRLS(input.tenantId, async (tx) => {
const filters = await resolveFacetFiltersWithTx(tx, input.tenantId, input.filters);
const projects = await tx.projekt.findMany({
where: projectListWhere({ tenantId: input.tenantId, ctx: input.ctx, filters }),
select: PROJECT_LIST_SELECT,
orderBy: projectListOrderBy(input.sort),
take: PROJECT_CSV_EXPORT_MAX_ROWS + 1,
});
if (projects.length > PROJECT_CSV_EXPORT_MAX_ROWS) {
throw new ProjectServiceError("projects.error.exportTooLarge");
}
return buildProjectListRowsWithTx(tx, {
tenantId: input.tenantId,
ctx: input.ctx,
projects: projects as ProjectListDbRow[],
});
});
const header = ["Nummer", "Name", "Pipeline", "Status", "Volumen", "Zuständig", "Aktualisiert"];
const csvRows = rows.map((row) =>
[row.projektNummer, row.projektname, row.pipelineName, row.stageName, row.valueVisible ? row.value : "—", row.ownerUserId, row.updatedAt]
.map(csvCell)
.join(",")
);
return `${header.map(csvCell).join(",")}\n${csvRows.join("\n")}\n`;
}
export async function rebuildPipelineAggregateCache(input: {
tenantId: string;
pipelineId: string;
includeDemoData?: boolean;
ctx?: ProjectVisibilityContext;
}): Promise<PipelineAggregateSnapshotRow[]> {
if (input.ctx && !hasPermission(input.ctx, "projects.admin")) {
throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
}
return withTenantRLS(input.tenantId, async (tx) => {
const pipeline = await assertPipelineReadableWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId, ctx: input.ctx });
const [stages, projects, lostReasons] = await Promise.all([
tx.pipelineStage.findMany({ where: { tenantId: input.tenantId, pipelineId: pipeline.id, archivedAt: null }, orderBy: { position: "asc" } }),
tx.projekt.findMany({
where: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
archivedAt: null,
...(input.includeDemoData ? {} : { isDemoData: false }),
},
}),
tx.lostReason.findMany({ where: { tenantId: input.tenantId }, select: { id: true, name: true } }),
]);
const now = new Date();
const lostReasonNameById = new Map(lostReasons.map((reason) => [reason.id, reason.name]));
const snapshots: PipelineAggregateSnapshotRow[] = [];
const pipelineProjectCount = (projects as ProjektRow[]).length;
let totalProjectCount = 0;
let totalPotential = 0;
let totalOrder = 0;
let totalWeighted = 0;
let totalRealized = 0;
let totalLost = 0;
const totalLostReasons: Record<string, number> = {};
for (const stage of stages as PipelineStageRow[]) {
const stageProjects = (projects as ProjektRow[]).filter((project) => project.stageId === stage.id);
const projectCount = stageProjects.length;
const potentialValue = stageProjects.reduce((sum, project) => sum + decimalToNumber(project.potenziellerAuftragswert), 0);
const orderVolume = stageProjects.reduce((sum, project) => sum + decimalToNumber(project.auftragsvolumen), 0);
const weightedForecast = stageProjects.reduce(
(sum, project) => sum + decimalToNumber(project.potenziellerAuftragswert ?? project.auftragsvolumen) * (stage.winProbability / 100),
0
);
const realizedRevenue =
stage.stageKind === "gewonnen"
? stageProjects.reduce((sum, project) => sum + decimalToNumber(project.auftragsvolumen ?? project.potenziellerAuftragswert), 0)
: 0;
const lostCount = stage.stageKind === "verloren" ? projectCount : 0;
const lostReasonDistribution: Record<string, number> = {};
for (const project of stageProjects) {
if (!project.lostReasonId) continue;
const name = lostReasonNameById.get(project.lostReasonId) ?? project.lostReasonId;
lostReasonDistribution[name] = (lostReasonDistribution[name] ?? 0) + 1;
totalLostReasons[name] = (totalLostReasons[name] ?? 0) + 1;
}
totalProjectCount += projectCount;
totalPotential += potentialValue;
totalOrder += orderVolume;
totalWeighted += weightedForecast;
totalRealized += realizedRevenue;
totalLost += lostCount;
// Share of the pipeline's projects that ever reached this stage. Counted per
// distinct project (a project bounced back and forth must not inflate its own
// stage), against the pipeline total — not against `totalProjectCount`, which
// is still accumulating inside this loop and would make the rate depend on
// stage iteration order. Demo projects are excluded on both sides.
const stageEntrants = await tx.projektStageChange.groupBy({
by: ["projektId"],
where: {
tenantId: input.tenantId,
toStageId: stage.id,
undoneAt: null,
...(input.includeDemoData ? {} : { projekt: { is: { isDemoData: false } } }),
},
});
const stageConversionRate = pipelineProjectCount > 0 ? stageEntrants.length / pipelineProjectCount : null;
const snapshot = await tx.pipelineAggregateSnapshot.upsert({
where: { tenantId_pipelineId_scopeKey: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey: `stage:${stage.id}` } },
update: {
stageId: stage.id,
projectCount,
potentialValue,
orderVolume,
weightedForecast,
realizedRevenue,
lostCount,
stageConversionRate,
lostReasonDistribution,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
create: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
stageId: stage.id,
scopeKey: `stage:${stage.id}`,
projectCount,
potentialValue,
orderVolume,
weightedForecast,
realizedRevenue,
lostCount,
stageConversionRate,
lostReasonDistribution,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
});
snapshots.push(snapshot as PipelineAggregateSnapshotRow);
}
const pipelineSnapshot = await tx.pipelineAggregateSnapshot.upsert({
where: { tenantId_pipelineId_scopeKey: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey: "pipeline" } },
update: {
stageId: null,
projectCount: totalProjectCount,
potentialValue: totalPotential,
orderVolume: totalOrder,
weightedForecast: totalWeighted,
realizedRevenue: totalRealized,
lostCount: totalLost,
stageConversionRate: null,
lostReasonDistribution: totalLostReasons,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
create: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
stageId: null,
scopeKey: "pipeline",
projectCount: totalProjectCount,
potentialValue: totalPotential,
orderVolume: totalOrder,
weightedForecast: totalWeighted,
realizedRevenue: totalRealized,
lostCount: totalLost,
stageConversionRate: null,
lostReasonDistribution: totalLostReasons,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
});
// Owner-scoped KPI rows (REQ-projekt-forecast-kpis: "KPIs pro Verantwortlichem").
// One `owner:<userId>` snapshot per zustaendigePersonId that currently holds
// projects in this pipeline; owners without projects get their stale row removed.
const stageById = new Map((stages as PipelineStageRow[]).map((stage) => [stage.id, stage]));
type OwnerAggregate = {
projectCount: number;
potentialValue: number;
orderVolume: number;
weightedForecast: number;
realizedRevenue: number;
lostCount: number;
lostReasonDistribution: Record<string, number>;
};
const ownerAggregates = new Map<string, OwnerAggregate>();
for (const project of projects as ProjektRow[]) {
const ownerUserId = project.zustaendigePersonId;
if (!ownerUserId) continue;
const stage = stageById.get(project.stageId);
if (!stage) continue;
const aggregate =
ownerAggregates.get(ownerUserId) ??
{ projectCount: 0, potentialValue: 0, orderVolume: 0, weightedForecast: 0, realizedRevenue: 0, lostCount: 0, lostReasonDistribution: {} };
aggregate.projectCount += 1;
aggregate.potentialValue += decimalToNumber(project.potenziellerAuftragswert);
aggregate.orderVolume += decimalToNumber(project.auftragsvolumen);
aggregate.weightedForecast += decimalToNumber(project.potenziellerAuftragswert ?? project.auftragsvolumen) * (stage.winProbability / 100);
if (stage.stageKind === "gewonnen") {
aggregate.realizedRevenue += decimalToNumber(project.auftragsvolumen ?? project.potenziellerAuftragswert);
}
if (stage.stageKind === "verloren") {
aggregate.lostCount += 1;
if (project.lostReasonId) {
const name = lostReasonNameById.get(project.lostReasonId) ?? project.lostReasonId;
aggregate.lostReasonDistribution[name] = (aggregate.lostReasonDistribution[name] ?? 0) + 1;
}
}
ownerAggregates.set(ownerUserId, aggregate);
}
const ownerScopeKeys: string[] = [];
for (const [ownerUserId, aggregate] of ownerAggregates) {
const scopeKey = `owner:${ownerUserId}`;
ownerScopeKeys.push(scopeKey);
const ownerValues = {
stageId: null,
projectCount: aggregate.projectCount,
potentialValue: aggregate.potentialValue,
orderVolume: aggregate.orderVolume,
weightedForecast: aggregate.weightedForecast,
realizedRevenue: aggregate.realizedRevenue,
lostCount: aggregate.lostCount,
stageConversionRate: null,
lostReasonDistribution: aggregate.lostReasonDistribution,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
};
const ownerSnapshot = await tx.pipelineAggregateSnapshot.upsert({
where: { tenantId_pipelineId_scopeKey: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey } },
update: ownerValues,
create: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey, ...ownerValues },
});
snapshots.push(ownerSnapshot as PipelineAggregateSnapshotRow);
}
await tx.pipelineAggregateSnapshot.deleteMany({
where: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey: { startsWith: "owner:", notIn: ownerScopeKeys } },
});
return [pipelineSnapshot as PipelineAggregateSnapshotRow, ...snapshots];
});
}
/**
* Refresh the forecast/KPI aggregate cache after a project or stage mutation.
* The cache is derived data: a refresh failure must never fail the write that
* triggered it, so this logs a structured `*-failed` line and continues. Runs
* outside the mutation transaction (after commit) so it aggregates the
* committed state; callers pass every pipeline the mutation touched.
*/
const aggregateLogger = createScopedLogger("projects.aggregate-cache");
export async function refreshPipelineAggregatesAfterMutation(
tenantId: string,
pipelineIds: ReadonlyArray<string | null | undefined>
): Promise<void> {
const unique = [...new Set(pipelineIds.filter((id): id is string => typeof id === "string" && id.length > 0))];
for (const pipelineId of unique) {
try {
await rebuildPipelineAggregateCache({ tenantId, pipelineId });
} catch (error) {
aggregateLogger.error(
{ tenantId, pipelineId, message: error instanceof Error ? error.message : String(error) },
"project-aggregate-refresh-failed"
);
}
}
}
export async function getPipelineForecast(input: { tenantId: string; pipelineId: string; ctx: ProjectVisibilityContext }): Promise<PipelineForecastVM> {
// Persisted snapshots are tenant-wide by construction. Owner/member-scoped
// readers must never receive their counts or conversion data as if the cache
// were user-scoped; a future scoped snapshot can relax this guard explicitly.
if (!canReadAllProjects(input.ctx)) {
throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
}
return withTenantRLS(input.tenantId, async (tx) => {
await assertPipelineReadableWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId, ctx: input.ctx });
const [aggregateVisible, stages, snapshots] = await Promise.all([
canViewAggregatesWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId, ctx: input.ctx }),
tx.pipelineStage.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null },
orderBy: { position: "asc" },
}),
tx.pipelineAggregateSnapshot.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId },
}),
]);
const pipelineSnapshot = snapshots.find((snapshot) => snapshot.scopeKey === "pipeline");
if (!pipelineSnapshot) throw new ProjectServiceError("projects.error.aggregateMissing");
const stageConversionRates = stages.map((stage) => {
const snapshot = snapshots.find((candidate) => candidate.scopeKey === `stage:${stage.id}`);
return {
stageId: stage.id,
stageName: stage.name,
rate: snapshot?.stageConversionRate == null ? null : decimalToNumber(snapshot.stageConversionRate),
};
});
return {
pipelineId: input.pipelineId,
aggregateVisible,
projectCount: pipelineSnapshot.projectCount,
potentialValue: aggregateVisible ? decimalToString(pipelineSnapshot.potentialValue) : null,
orderVolume: aggregateVisible ? decimalToString(pipelineSnapshot.orderVolume) : null,
weightedForecast: aggregateVisible ? decimalToString(pipelineSnapshot.weightedForecast) : null,
realizedRevenue: aggregateVisible ? decimalToString(pipelineSnapshot.realizedRevenue) : null,
lostCount: pipelineSnapshot.lostCount,
stageConversionRates,
lostReasonDistribution: (pipelineSnapshot.lostReasonDistribution ?? {}) as Record<string, number>,
ownerKpis: snapshots
.filter((snapshot) => snapshot.scopeKey.startsWith("owner:"))
.map((snapshot) => ({
ownerUserId: snapshot.scopeKey.slice("owner:".length),
projectCount: snapshot.projectCount,
weightedForecast: aggregateVisible ? decimalToString(snapshot.weightedForecast) : null,
realizedRevenue: aggregateVisible ? decimalToString(snapshot.realizedRevenue) : null,
lostCount: snapshot.lostCount,
}))
.sort((a, b) => a.ownerUserId.localeCompare(b.ownerUserId)),
calculatedAt: pipelineSnapshot.calculatedAt.toISOString(),
};
});
}
export async function moveProjectStage(input: {
tenantId: string;
projectId: string;
targetStageId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
lostReasonId?: string | null;
/** WF-P8 M01: optional free-text note stored with the stage change. */
notiz?: string | null;
undoWindowHours?: number;
/** Internal: bulk callers refresh the aggregate cache once at the end instead of per project. */
refreshAggregates?: boolean;
}): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.change_stage")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const moved = await withTenantRLS(input.tenantId, async (tx) => {
const now = new Date();
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectEditScopeWhere(input.tenantId, input.ctx),
},
include: { stage: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
await assertKwpProjectMutationAllowed({
tenantId: input.tenantId,
projectId: project.id,
operation: "stage",
client: tx,
});
const targetStage = await tx.pipelineStage.findFirst({
where: { id: input.targetStageId, tenantId: input.tenantId, pipelineId: project.pipelineId, archivedAt: null },
});
if (!targetStage) throw new CatalogError(foundationErrors.notFound, { stageId: input.targetStageId });
const auftragStageConfig = await tx.auftragPipelineStageConfig.findFirst({ where: { tenantId: input.tenantId, stageId: targetStage.id } });
if (auftragStageConfig?.requiresAcceptanceProtocol) {
// R-P9b DoD 6 — only a completed acceptance opens the gate: full or
// with_defects (partial acceptance). A rejected protocol does not count;
// it moves the project back into execution instead.
const acceptanceProtocol = await tx.abnahmeProtokoll.findFirst({
where: {
tenantId: input.tenantId,
projektId: project.id,
gesperrtAt: { not: null },
archivedAt: null,
acceptanceKind: { in: ["full", "with_defects"] },
},
select: { id: true },
});
if (!acceptanceProtocol) throw new ProjectServiceError("projects.error.acceptanceProtocolRequired");
}
if (targetStage.stageKind === "verloren") {
const lostReasonId = normalizeText(input.lostReasonId);
if (!lostReasonId) throw new ProjectServiceError("projects.error.lostReasonRequired");
const lostReason = await tx.lostReason.findFirst({ where: { id: lostReasonId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!lostReason) throw new CatalogError(foundationErrors.notFound, { lostReasonId });
}
const undoUntil = targetStage.stageKind === "gewonnen" || targetStage.stageKind === "verloren" ? addHours(now, input.undoWindowHours ?? 24) : null;
const updated = await tx.projekt.update({
where: { id: project.id, tenantId: input.tenantId },
data: {
stageId: targetStage.id,
wonAt: targetStage.stageKind === "gewonnen" ? now : targetStage.stageKind === "offen" ? null : project.wonAt,
lostAt: targetStage.stageKind === "verloren" ? now : targetStage.stageKind === "offen" ? null : project.lostAt,
lostReasonId: targetStage.stageKind === "verloren" ? input.lostReasonId : null,
undoStageChangeUntil: undoUntil,
// A forward move ends the redo offer: what would be redone is no longer
// the last thing that happened to this project.
redoStageId: null,
redoLostReasonId: null,
},
});
const stageChange = await tx.projektStageChange.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
fromStageId: project.stageId,
toStageId: targetStage.id,
lostReasonId: targetStage.stageKind === "verloren" ? input.lostReasonId : null,
notiz: normalizeText(input.notiz),
changedByUserId: input.userId,
undoUntil,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_stage_changed",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.stageChanged",
metadata: { fromStageId: project.stageId, toStageId: targetStage.id, stageKind: targetStage.stageKind },
},
{ client: tx }
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.stage_changed",
data: { ...workflowPayload(updated as ProjektRow), fromStageId: project.stageId, toStageId: targetStage.id, stageKind: targetStage.stageKind },
userId: input.userId,
sourceId: `project:${project.id}:stage:${targetStage.id}:${now.getTime()}`,
})
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildPortalStatusMailOutboxEvent(
{
tenantId: input.tenantId,
projectId: project.id,
previousStageId: project.stageId,
expectedStageId: targetStage.id,
stageChangeId: stageChange.id,
},
{ now }
)
);
if (targetStage.stageKind === "gewonnen") {
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "deal.won",
data: {
...workflowPayload(updated as ProjektRow),
wonAt: now.toISOString(),
orderVolume: decimalToString(updated.auftragsvolumen ?? updated.potenziellerAuftragswert),
},
userId: input.userId,
sourceId: `project:${project.id}:won:${now.getTime()}`,
})
);
}
await publishProjectSearchUpsert(tx, updated as ProjektRow);
await ensureAndSyncProjectChannelIfEnabledWithTx(tx, {
tenantId: input.tenantId,
projectId: updated.id,
actorUserId: input.userId,
requestId: `project:${updated.id}:stage:${targetStage.id}:chat-sync`,
reactivateArchived: project.stage.stageKind !== "offen" && targetStage.stageKind === "offen",
});
return updated as ProjektRow;
});
if (input.refreshAggregates !== false) {
await refreshPipelineAggregatesAfterMutation(input.tenantId, [moved.pipelineId]);
}
return moved;
}
// --- R-P8 interim UI: minimal read/write extensions -----------------------------
// Added for the AD-28 interim screens (Projekt-Detail inline-edit, Pipeline stage
// editor, Kanban bulk-move). No schema change — these operate on existing models.
export type UpdateProjectStammdatenInput = {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
projektname?: string;
beschreibung?: string | null;
prioritaet?: ProjektPrioritaet;
potenziellerAuftragswert?: string | number | null;
auftragsvolumen?: string | number | null;
startDatum?: string | null;
deadline?: string | null;
zustaendigePersonId?: string | null;
projektleiterId?: string | null;
// WF-P8 S02 "Grunddaten" / "Projektkontaktdaten".
projekttyp?: string | null;
quelle?: string | null;
promotion?: string | null;
email?: string | null;
telefon?: string | null;
fax?: string | null;
};
function parseOptionalDate(value: string | null | undefined): Date | null | undefined {
if (value === undefined) return undefined;
const trimmed = value?.trim();
if (!trimmed) return null;
const date = new Date(trimmed);
if (Number.isNaN(date.getTime())) throw new ProjectServiceError("projects.error.invalidDate");
return date;
}
function kontaktDisplayLabel(k: { vorname: string | null; nachname: string | null; orgName: string | null; primaerEmail: string | null }): string {
const person = `${k.vorname ?? ""} ${k.nachname ?? ""}`.trim();
return normalizeText(k.orgName) ?? (person || null) ?? normalizeText(k.primaerEmail) ?? "";
}
export async function updateProjectStammdaten(input: UpdateProjectStammdatenInput): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const updatedProject = 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),
},
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
await assertKwpProjectMutationAllowed({
tenantId: input.tenantId,
projectId: project.id,
operation: "stammdaten",
client: tx,
changedFields: [
...(input.projektname !== undefined ? ["projektname"] : []),
...(input.startDatum !== undefined ? ["startDatum"] : []),
...(input.auftragsvolumen !== undefined ? ["auftragsvolumen"] : []),
],
});
const data: Record<string, unknown> = {};
const zustaendigePersonId = input.zustaendigePersonId === undefined ? undefined : normalizeText(input.zustaendigePersonId);
const projektleiterId = input.projektleiterId === undefined ? undefined : normalizeText(input.projektleiterId);
await assertTenantUsersOwnedWithTx(tx, input.tenantId, [
{ userId: zustaendigePersonId ?? null, field: "zustaendigePersonId" },
{ userId: projektleiterId ?? null, field: "projektleiterId" },
]);
if (input.projektname !== undefined) {
const name = normalizeText(input.projektname);
if (!name) throw new ProjectServiceError("projects.error.nameRequired");
data.projektname = name;
}
if (input.beschreibung !== undefined) data.beschreibung = normalizeText(input.beschreibung);
if (input.prioritaet !== undefined) data.prioritaet = input.prioritaet;
if (input.projekttyp !== undefined) data.projekttyp = normalizeText(input.projekttyp);
if (input.quelle !== undefined) data.quelle = normalizeText(input.quelle);
if (input.promotion !== undefined) data.promotion = normalizeText(input.promotion);
if (input.email !== undefined) data.email = normalizeText(input.email);
if (input.telefon !== undefined) data.telefon = normalizeText(input.telefon);
if (input.fax !== undefined) data.fax = normalizeText(input.fax);
if (input.zustaendigePersonId !== undefined) data.zustaendigePersonId = zustaendigePersonId;
if (input.projektleiterId !== undefined) data.projektleiterId = projektleiterId;
const startDatum = parseOptionalDate(input.startDatum);
if (startDatum !== undefined) data.startDatum = startDatum;
const deadline = parseOptionalDate(input.deadline);
if (deadline !== undefined) data.deadline = deadline;
// Volume fields are field-level gated: only writable with view permission.
if (input.potenziellerAuftragswert !== undefined || input.auftragsvolumen !== undefined) {
const volumeAccess = await resolveProjectVolumeAccessWithTx(tx, project as ProjektRow, input.ctx);
if (!volumeAccess.canEdit) throw new ProjectServiceError("projects.error.volumeNotEditable");
if (input.potenziellerAuftragswert !== undefined) {
data.potenziellerAuftragswert =
input.potenziellerAuftragswert === null || input.potenziellerAuftragswert === "" ? null : input.potenziellerAuftragswert;
}
if (input.auftragsvolumen !== undefined) {
data.auftragsvolumen = input.auftragsvolumen === null || input.auftragsvolumen === "" ? null : input.auftragsvolumen;
}
}
const updated = await tx.projekt.update({ where: { id: project.id, tenantId: input.tenantId }, data });
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.detailsUpdated",
metadata: { fields: Object.keys(data) },
},
{ client: tx }
);
await publishProjectSearchUpsert(tx, updated as ProjektRow);
if (input.projektname !== undefined || input.zustaendigePersonId !== undefined || input.projektleiterId !== undefined) {
await ensureAndSyncProjectChannelIfEnabledWithTx(tx, {
tenantId: input.tenantId,
projectId: updated.id,
actorUserId: input.userId,
requestId: `project:${updated.id}:chat-stammdaten-sync`,
});
}
return updated as ProjektRow;
});
// Volume/owner fields feed the forecast cache (weighted forecast, owner KPIs).
await refreshPipelineAggregatesAfterMutation(input.tenantId, [updatedProject.pipelineId]);
return updatedProject;
}
export type ProjektKontaktDetail = {
id: string;
kontaktId: string;
/** Drives the WF-P8 S02 split into the company card and the person cards. */
kontaktTyp: KontaktTyp;
rolle: ProjektKontaktRolle;
primaer: boolean;
label: string;
email: string | null;
telefon: string | null;
/** WF-P8 S02 card master data. Company fields for an organisation, person
* fields for a privatperson; null throughout when unset. */
website: string | null;
ustIdNr: string | null;
handelsregister: string | null;
kundennummer: string | null;
kundeSeit: Date | null;
branche: string | null;
mitarbeiterzahl: number | null;
position: string | null;
erreichbarkeit: string | null;
notizen: string | null;
/** Typed numbers from `KontaktTelefon`; the card shows them as own rows. */
durchwahl: string | null;
mobil: string | null;
fax: string | null;
/** One-line postal address of the contact, or null when it has none. */
adresseLabel: string | null;
portalInviteEligible: boolean;
};
/**
* WF-P8 S02 "Adressdaten": the project's three address slots. They are plain
* scalar FKs on `Projekt` (no Prisma relation), so they are resolved with one
* extra tenant-scoped query instead of an include.
*/
export const PROJECT_ADDRESS_SLOTS = ["projekt", "rechnung", "bauherr"] as const;
export type ProjectAddressSlot = (typeof PROJECT_ADDRESS_SLOTS)[number];
const ADDRESS_SLOT_FIELD: Record<ProjectAddressSlot, "projektAdresseId" | "rechnungsAdresseId" | "bauherrAdresseId"> = {
projekt: "projektAdresseId",
rechnung: "rechnungsAdresseId",
bauherr: "bauherrAdresseId",
};
export type ProjektAdresse = {
id: string;
strasse: string | null;
hausnummer: string | null;
plz: string | null;
ort: string | null;
land: string;
/** Slot extras from ProjektAdressDetail; defaults when the row does not exist. */
bauabschnitt: string | null;
zufahrt: string | null;
koordinaten: string | null;
empfaenger: string | null;
zahlungsziel: string | null;
rechnungsEmail: string | null;
postErwuenscht: boolean;
telefon: string | null;
ansprechpartnerVorOrt: string | null;
notizen: string | null;
};
const EMPTY_ADDRESS_EXTRAS: ProjektAdressExtras = {
bauabschnitt: null,
zufahrt: null,
koordinaten: null,
empfaenger: null,
zahlungsziel: null,
rechnungsEmail: null,
postErwuenscht: false,
telefon: null,
ansprechpartnerVorOrt: null,
notizen: null,
};
export type ProjectDetailData = {
project: ProjektRow;
pipeline: PipelineRow;
stages: PipelineStageRow[];
currentStage: PipelineStageRow;
kontakte: ProjektKontaktDetail[];
lostReasons: LostReasonRow[];
/** Null per slot when the project has no address in that slot. */
adressen: Record<ProjectAddressSlot, ProjektAdresse | null>;
canViewVolume: boolean;
};
/**
* The contact's own postal address as one line. The S02 card shows it read-only
* next to the channels, so a formatted string is all the screen needs.
*/
function formatContactAddress(
address: { strasse: string | null; hausnummer: string | null; plz: string | null; ort: string | null } | null
): string | null {
if (!address) return null;
const street = [address.strasse, address.hausnummer].filter((part) => part?.trim()).join(" ");
const city = [address.plz, address.ort].filter((part) => part?.trim()).join(" ");
const line = [street, city].filter(Boolean).join(", ");
return line || null;
}
export type ProjectAddressInput = {
strasse?: string | null;
hausnummer?: string | null;
plz?: string | null;
ort?: string | null;
land?: string | null;
// WF-P8 S02 slot extras (ProjektAdressDetail). Which of them a slot shows is
// the UI's business; the service just stores what it is given for that slot.
bauabschnitt?: string | null;
zufahrt?: string | null;
koordinaten?: string | null;
empfaenger?: string | null;
zahlungsziel?: string | null;
rechnungsEmail?: string | null;
postErwuenscht?: boolean | null;
telefon?: string | null;
ansprechpartnerVorOrt?: string | null;
notizen?: string | null;
};
/** The per-slot extras, separated from the shared `Adresse` columns. */
export type ProjektAdressExtras = {
bauabschnitt: string | null;
zufahrt: string | null;
koordinaten: string | null;
empfaenger: string | null;
zahlungsziel: string | null;
rechnungsEmail: string | null;
postErwuenscht: boolean;
telefon: string | null;
ansprechpartnerVorOrt: string | null;
notizen: string | null;
};
export type UpdateProjectAddressesInput = {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
/** Only the slots present are touched; `null` clears the slot. */
slots: Partial<Record<ProjectAddressSlot, ProjectAddressInput | null>>;
};
function normalizeAddress(input: ProjectAddressInput): { strasse: string | null; hausnummer: string | null; plz: string | null; ort: string | null; land: string } {
return {
strasse: normalizeText(input.strasse),
hausnummer: normalizeText(input.hausnummer),
plz: normalizeText(input.plz),
ort: normalizeText(input.ort),
land: normalizeText(input.land) ?? "DE",
};
}
function normalizeAddressExtras(input: ProjectAddressInput): ProjektAdressExtras {
return {
bauabschnitt: normalizeText(input.bauabschnitt),
zufahrt: normalizeText(input.zufahrt),
koordinaten: normalizeText(input.koordinaten),
empfaenger: normalizeText(input.empfaenger),
zahlungsziel: normalizeText(input.zahlungsziel),
rechnungsEmail: normalizeText(input.rechnungsEmail),
postErwuenscht: input.postErwuenscht === true,
telefon: normalizeText(input.telefon),
ansprechpartnerVorOrt: normalizeText(input.ansprechpartnerVorOrt),
notizen: normalizeText(input.notizen),
};
}
/** All-default extras carry no information, so they get no row of their own. */
function extrasAreEmpty(extras: ProjektAdressExtras): boolean {
return (
!extras.bauabschnitt &&
!extras.zufahrt &&
!extras.koordinaten &&
!extras.empfaenger &&
!extras.zahlungsziel &&
!extras.rechnungsEmail &&
extras.postErwuenscht === false &&
!extras.telefon &&
!extras.ansprechpartnerVorOrt &&
!extras.notizen
);
}
async function upsertAddressExtrasWithTx(
tx: TenantRlsClient,
tenantId: string,
projektId: string,
slot: ProjectAddressSlot,
extras: ProjektAdressExtras
): Promise<void> {
if (extrasAreEmpty(extras)) {
await tx.projektAdressDetail.deleteMany({ where: { tenantId, projektId, slot } });
return;
}
const existing = await tx.projektAdressDetail.findFirst({ where: { tenantId, projektId, slot }, select: { id: true } });
if (existing) {
await tx.projektAdressDetail.update({ where: { id: existing.id, tenantId }, data: extras });
return;
}
await tx.projektAdressDetail.create({ data: { tenantId, projektId, slot, ...extras } });
}
export type LinkProjektKontaktInput = {
tenantId: string;
projectId: string;
kontaktId: string;
rolle?: ProjektKontaktRolle;
userId: string | null;
ctx: ProjectVisibilityContext;
};
/**
* WF-P8 S02 "Firma oder Kontakt hinzufügen": link an existing contact to the
* project.
*
* Idempotent on (projekt, kontakt): the same contact appearing twice on one
* project is not a second relationship, it is a double click. The first link a
* project gets becomes the primary one, so the board card and the list have a
* customer to show without a second decision.
*/
export async function linkProjektKontakt(input: LinkProjektKontaktInput): Promise<void> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
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: { id: true, projektNummer: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
// The contact has to exist in this tenant: ProjektKontakt carries a bare
// `kontaktId` without a foreign key, so nothing else would catch a typo.
const kontakt = await tx.kontakt.findFirst({
where: { id: input.kontaktId, tenantId: input.tenantId, archivedAt: null },
select: { id: true, kontaktTyp: true, vorname: true, nachname: true, orgName: true, primaerEmail: true },
});
if (!kontakt) throw new CatalogError(foundationErrors.notFound, { kontaktId: input.kontaktId });
const existing = await tx.projektKontakt.findFirst({
where: { tenantId: input.tenantId, projektId: project.id, kontaktId: kontakt.id },
select: { id: true },
});
if (existing) return;
const linkCount = await tx.projektKontakt.count({ where: { tenantId: input.tenantId, projektId: project.id } });
await tx.projektKontakt.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
kontaktId: kontakt.id,
rolle: input.rolle ?? "ansprechpartner",
primaer: linkCount === 0,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.contactLinked",
metadata: { kontaktId: kontakt.id, rolle: input.rolle ?? "ansprechpartner" },
},
{ client: tx }
);
});
}
export type UnlinkProjektKontaktInput = {
tenantId: string;
projectId: string;
/** Row of the M:N bridge, not the contact — the contact stays in the CRM. */
projektKontaktId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
};
/**
* WF-P8 S02: drop the link between a project and a contact.
*
* Only the bridge row goes; the `Kontakt` belongs to the CRM and is never
* deleted from here. If the primary link is the one removed, the oldest
* remaining link takes over, so a project with contacts always has a primary
* one for the board card and the list to show.
*/
export async function unlinkProjektKontakt(input: UnlinkProjektKontaktInput): Promise<void> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
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: { id: true, projektNummer: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const link = await tx.projektKontakt.findFirst({
where: { id: input.projektKontaktId, tenantId: input.tenantId, projektId: project.id },
select: { id: true, kontaktId: true, primaer: true },
});
if (!link) throw new CatalogError(foundationErrors.notFound, { projektKontaktId: input.projektKontaktId });
await tx.projektKontakt.delete({ where: { id: link.id } });
if (link.primaer) {
const next = await tx.projektKontakt.findFirst({
where: { tenantId: input.tenantId, projektId: project.id },
orderBy: { createdAt: "asc" },
select: { id: true },
});
if (next) await tx.projektKontakt.update({ where: { id: next.id }, data: { primaer: true } });
}
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.contactUnlinked",
metadata: { kontaktId: link.kontaktId },
},
{ client: tx }
);
});
}
/**
* Write the S02 address slots. A slot with content upserts its `Adresse` row; a
* slot set to null (or emptied out) detaches it and removes the row unless a
* contact still points at it — `Adresse` is a shared CRM entity, so a project
* must never delete an address another record uses.
*/
export async function updateProjectAddresses(input: UpdateProjectAddressesInput): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
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),
},
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const data: Record<string, string | null> = {};
const touched: ProjectAddressSlot[] = [];
for (const slot of PROJECT_ADDRESS_SLOTS) {
if (!(slot in input.slots)) continue;
touched.push(slot);
const field = ADDRESS_SLOT_FIELD[slot];
const currentId = (project as ProjektRow)[field] ?? null;
const raw = input.slots[slot];
const next = raw ? normalizeAddress(raw) : null;
const isEmpty = !next || (!next.strasse && !next.hausnummer && !next.plz && !next.ort);
if (isEmpty) {
// Clearing the slot drops its extras too: they describe that address, so
// keeping them would resurface stale hints on the next address.
await tx.projektAdressDetail.deleteMany({ where: { tenantId: input.tenantId, projektId: project.id, slot } });
if (!currentId) continue;
data[field] = null;
const stillUsed = await tx.kontakt.count({ where: { tenantId: input.tenantId, adresseId: currentId } });
if (stillUsed === 0) await tx.adresse.deleteMany({ where: { id: currentId, tenantId: input.tenantId } });
continue;
}
if (currentId) {
await tx.adresse.update({ where: { id: currentId, tenantId: input.tenantId }, data: next });
} else {
const created = await tx.adresse.create({ data: { tenantId: input.tenantId, ...next } });
data[field] = created.id;
}
// `raw` is non-null here: an empty slot already took the branch above.
await upsertAddressExtrasWithTx(tx, input.tenantId, project.id, slot, normalizeAddressExtras(raw ?? {}));
}
const updated = Object.keys(data).length
? await tx.projekt.update({ where: { id: project.id, tenantId: input.tenantId }, data })
: (project as ProjektRow);
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.addressesUpdated",
metadata: { slots: touched },
},
{ client: tx }
);
return updated as ProjektRow;
});
}
async function loadProjectAddressesWithTx(
tx: TenantRlsClient,
tenantId: string,
project: Pick<ProjektRow, "id" | "projektAdresseId" | "rechnungsAdresseId" | "bauherrAdresseId">
): Promise<Record<ProjectAddressSlot, ProjektAdresse | null>> {
const ids = PROJECT_ADDRESS_SLOTS.map((slot) => project[ADDRESS_SLOT_FIELD[slot]]).filter((id): id is string => Boolean(id));
const [rows, extraRows] = await Promise.all([
ids.length
? tx.adresse.findMany({
where: { tenantId, id: { in: ids } },
select: { id: true, strasse: true, hausnummer: true, plz: true, ort: true, land: true },
})
: Promise.resolve([]),
tx.projektAdressDetail.findMany({
where: { tenantId, projektId: project.id },
select: {
slot: true,
bauabschnitt: true,
zufahrt: true,
koordinaten: true,
empfaenger: true,
zahlungsziel: true,
rechnungsEmail: true,
postErwuenscht: true,
telefon: true,
ansprechpartnerVorOrt: true,
notizen: true,
},
}),
]);
const extrasBySlot = new Map(extraRows.map((row) => [row.slot as ProjectAddressSlot, row]));
const byId = new Map(
rows.map((row) => {
const slot = PROJECT_ADDRESS_SLOTS.find((candidate) => project[ADDRESS_SLOT_FIELD[candidate]] === row.id);
const extras = slot ? extrasBySlot.get(slot) : undefined;
const address: ProjektAdresse = {
...row,
bauabschnitt: extras?.bauabschnitt ?? EMPTY_ADDRESS_EXTRAS.bauabschnitt,
zufahrt: extras?.zufahrt ?? EMPTY_ADDRESS_EXTRAS.zufahrt,
koordinaten: extras?.koordinaten ?? EMPTY_ADDRESS_EXTRAS.koordinaten,
empfaenger: extras?.empfaenger ?? EMPTY_ADDRESS_EXTRAS.empfaenger,
zahlungsziel: extras?.zahlungsziel ?? EMPTY_ADDRESS_EXTRAS.zahlungsziel,
rechnungsEmail: extras?.rechnungsEmail ?? EMPTY_ADDRESS_EXTRAS.rechnungsEmail,
postErwuenscht: extras?.postErwuenscht ?? EMPTY_ADDRESS_EXTRAS.postErwuenscht,
telefon: extras?.telefon ?? EMPTY_ADDRESS_EXTRAS.telefon,
ansprechpartnerVorOrt: extras?.ansprechpartnerVorOrt ?? EMPTY_ADDRESS_EXTRAS.ansprechpartnerVorOrt,
notizen: extras?.notizen ?? EMPTY_ADDRESS_EXTRAS.notizen,
};
return [row.id, address];
})
);
return {
projekt: project.projektAdresseId ? (byId.get(project.projektAdresseId) ?? null) : null,
rechnung: project.rechnungsAdresseId ? (byId.get(project.rechnungsAdresseId) ?? null) : null,
bauherr: project.bauherrAdresseId ? (byId.get(project.bauherrAdresseId) ?? null) : null,
};
}
export async function loadProjectDetail(input: { tenantId: string; projectId: string; ctx: ProjectVisibilityContext }): Promise<ProjectDetailData> {
return withTenantRLS(input.tenantId, async (tx) => {
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectReadScopeWhere(input.tenantId, input.ctx),
},
include: { pipeline: true, stage: true, kontakte: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const [stages, lostReasons] = await Promise.all([
tx.pipelineStage.findMany({ where: { tenantId: input.tenantId, pipelineId: project.pipelineId, archivedAt: null }, orderBy: { position: "asc" } }),
tx.lostReason.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: { position: "asc" } }),
]);
const kontaktIds = project.kontakte.map((k) => k.kontaktId);
const kontaktRows = kontaktIds.length
? await tx.kontakt.findMany({
where: { tenantId: input.tenantId, id: { in: kontaktIds } },
select: {
id: true,
kontaktTyp: true,
vorname: true,
nachname: true,
orgName: true,
primaerEmail: true,
primaerTelefon: true,
website: true,
ustIdNr: true,
handelsregister: true,
kundennummer: true,
kundeSeit: true,
branche: true,
mitarbeiterzahl: true,
position: true,
erreichbarkeit: true,
notizen: true,
archivedAt: true,
adresse: { select: { strasse: true, hausnummer: true, plz: true, ort: true } },
},
})
: [];
// Typed numbers live in their own table, so the card can show Durchwahl and
// Mobil next to the primary number instead of one undifferentiated "Telefon".
// One query for all contacts of the project, not one per card.
const telefonRows = kontaktIds.length
? await tx.kontaktTelefon.findMany({
where: { tenantId: input.tenantId, kontaktId: { in: kontaktIds } },
select: { kontaktId: true, telefon: true, typ: true },
orderBy: { primaer: "desc" },
})
: [];
// First row of a type wins: the ordering puts the primary number first, and a
// second Mobil would have nowhere to render.
const telefonByKontakt = new Map<string, { durchwahl: string | null; mobil: string | null; fax: string | null }>();
for (const row of telefonRows) {
if (row.typ === "haupt") continue;
const entry = telefonByKontakt.get(row.kontaktId) ?? { durchwahl: null, mobil: null, fax: null };
if (entry[row.typ] === null) entry[row.typ] = row.telefon;
telefonByKontakt.set(row.kontaktId, entry);
}
const kontaktLabelById = new Map(kontaktRows.map((k) => [k.id, kontaktDisplayLabel(k)]));
const kontaktById = new Map(kontaktRows.map((kontakt) => [kontakt.id, kontakt]));
const [canViewVolume, adressen] = await Promise.all([
canViewProjectVolumeWithTx(tx, project as ProjektRow, input.ctx),
loadProjectAddressesWithTx(tx, input.tenantId, project as ProjektRow),
]);
return {
project: project as ProjektRow,
pipeline: project.pipeline as PipelineRow,
stages: stages as PipelineStageRow[],
currentStage: project.stage as PipelineStageRow,
kontakte: project.kontakte.map((k) => {
const kontakt = kontaktById.get(k.kontaktId);
return {
id: k.id,
kontaktId: k.kontaktId,
kontaktTyp: (kontakt?.kontaktTyp ?? "privatperson") as KontaktTyp,
rolle: k.rolle,
primaer: k.primaer,
label: kontaktLabelById.get(k.kontaktId) || k.kontaktId,
email: kontakt?.primaerEmail ?? null,
telefon: kontakt?.primaerTelefon ?? null,
website: kontakt?.website ?? null,
ustIdNr: kontakt?.ustIdNr ?? null,
handelsregister: kontakt?.handelsregister ?? null,
kundennummer: kontakt?.kundennummer ?? null,
kundeSeit: kontakt?.kundeSeit ?? null,
branche: kontakt?.branche ?? null,
mitarbeiterzahl: kontakt?.mitarbeiterzahl ?? null,
position: kontakt?.position ?? null,
erreichbarkeit: kontakt?.erreichbarkeit ?? null,
notizen: kontakt?.notizen ?? null,
durchwahl: telefonByKontakt.get(k.kontaktId)?.durchwahl ?? null,
mobil: telefonByKontakt.get(k.kontaktId)?.mobil ?? null,
fax: telefonByKontakt.get(k.kontaktId)?.fax ?? null,
adresseLabel: formatContactAddress(kontakt?.adresse ?? null),
portalInviteEligible:
kontakt?.kontaktTyp === "privatperson" &&
kontakt.archivedAt === null &&
Boolean(kontakt.vorname?.trim() && kontakt.nachname?.trim() && kontakt.primaerEmail?.trim()),
};
}),
lostReasons: lostReasons as LostReasonRow[],
adressen,
canViewVolume,
};
});
}
export type UpsertPipelineStageInput = {
tenantId: string;
pipelineId: string;
stageId?: string | null;
name: string;
stageKind: PipelineStageKind;
winProbability?: number;
color?: string;
position?: number;
};
async function assertPipelineHasWonLostWithTx(tx: TenantRlsClient, tenantId: string, pipelineId: string): Promise<void> {
const stages = await tx.pipelineStage.findMany({ where: { tenantId, pipelineId, archivedAt: null }, select: { name: true, stageKind: true } });
validatePipelineStages(stages);
}
export async function upsertPipelineStage(input: UpsertPipelineStageInput): Promise<PipelineStageRow> {
const stageRow = await withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.stageNameRequired");
const pipeline = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!pipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
if (input.stageId) {
const existing = await tx.pipelineStage.findFirst({
where: { id: input.stageId, tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null },
select: { id: true },
});
if (!existing) throw new CatalogError(foundationErrors.notFound, { stageId: input.stageId });
const row = await tx.pipelineStage.update({
where: { id: input.stageId, tenantId: input.tenantId },
data: {
name,
stageKind: input.stageKind,
winProbability: input.winProbability ?? undefined,
color: normalizeText(input.color) ?? undefined,
position: input.position ?? undefined,
},
});
await assertPipelineHasWonLostWithTx(tx, input.tenantId, input.pipelineId);
return row as PipelineStageRow;
}
const row = await tx.pipelineStage.create({
data: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
name,
stageKind: input.stageKind,
winProbability: input.winProbability ?? (input.stageKind === "gewonnen" ? 100 : 0),
color: normalizeText(input.color) ?? "#6b7280",
position: input.position ?? 0,
},
});
return row as PipelineStageRow;
});
// Win probability / stage kind changes shift the weighted forecast.
await refreshPipelineAggregatesAfterMutation(input.tenantId, [input.pipelineId]);
return stageRow;
}
export async function archivePipelineStage(input: { tenantId: string; stageId: string }): Promise<PipelineStageRow> {
const archivedStage = await withTenantRLS(input.tenantId, async (tx) => {
const stage = await tx.pipelineStage.findFirst({ where: { id: input.stageId, tenantId: input.tenantId, archivedAt: null } });
if (!stage) throw new CatalogError(foundationErrors.notFound, { stageId: input.stageId });
const projectsInStage = await tx.projekt.count({ where: { tenantId: input.tenantId, stageId: input.stageId, archivedAt: null } });
if (projectsInStage > 0) throw new ProjectServiceError("projects.error.stageStillHasProjects");
const row = await tx.pipelineStage.update({
where: { id: input.stageId, tenantId: input.tenantId },
data: { archivedAt: new Date() },
});
await assertPipelineHasWonLostWithTx(tx, input.tenantId, stage.pipelineId);
return row as PipelineStageRow;
});
await refreshPipelineAggregatesAfterMutation(input.tenantId, [archivedStage.pipelineId]);
return archivedStage;
}
/**
* Persist a drag&drop stage order for one pipeline. `stageIds` is the new
* top-to-bottom order; every id must belong to the pipeline (unknown/foreign
* ids are ignored). Positions are reassigned in fixed steps of 10 so the list
* stays stable and later single-stage moves keep room in between. Tenant-scoped
* via withTenantRLS; the write is auto-audited by the M001 Prisma middleware.
*/
export async function reorderPipelineStages(input: {
tenantId: string;
pipelineId: string;
stageIds: readonly string[];
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const pipeline = await tx.pipeline.findFirst({
where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null },
select: { id: true },
});
if (!pipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
const stages = await tx.pipelineStage.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null },
select: { id: true },
});
const validIds = new Set(stages.map((stage) => stage.id));
let position = 0;
for (const stageId of input.stageIds) {
if (!validIds.has(stageId)) continue;
position += 10;
await tx.pipelineStage.update({
where: { id: stageId, tenantId: input.tenantId },
data: { position },
});
}
});
}
function customFieldWertInput(wert: JsonValue | null): Prisma.InputJsonValue | typeof Prisma.JsonNull {
return wert === null || wert === undefined ? Prisma.JsonNull : (wert as Prisma.InputJsonValue);
}
/**
* Set a tenant custom-field value on a project. Storage is the polymorphic
* `CustomFieldValue` (entityType `projekt`, ENTITY-MODEL Domäne 5) — no
* project-specific value table. Validates project visibility/edit scope +
* `projects.write`, the definition's existence and its entityType match, then
* upserts the value. Tenant-scoped via withTenantRLS; the upsert is auto-audited
* by the M001 Prisma middleware, and a project Activity entry is logged (DoD 14).
*/
export async function setProjectCustomFieldValue(input: {
tenantId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
projectId: string;
definitionId: string;
wert: JsonValue | null;
}): Promise<void> {
if (!hasPermission(input.ctx, "projects.write")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
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: { id: true, projektNummer: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const definition = await tx.customFieldDefinition.findFirst({
where: { id: input.definitionId, tenantId: input.tenantId },
select: { entityType: true },
});
if (!definition) throw new CatalogError(foundationErrors.notFound, { definitionId: input.definitionId });
if (definition.entityType !== "projekt") throw new ProjectServiceError("projects.error.customFieldEntityMismatch");
await tx.customFieldValue.upsert({
where: { definitionId_entityId: { definitionId: input.definitionId, entityId: input.projectId } },
update: { wert: customFieldWertInput(input.wert) },
create: {
tenantId: input.tenantId,
definitionId: input.definitionId,
entityType: "projekt",
entityId: input.projectId,
wert: customFieldWertInput(input.wert),
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.customFieldUpdated",
metadata: { customFieldDefinitionId: input.definitionId },
},
{ client: tx },
);
});
}
export async function bulkMoveProjectStage(input: {
tenantId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
projectIds: readonly string[];
targetStageId: string;
lostReasonId?: string | null;
}): Promise<{ movedCount: number; pipelineIds: string[] }> {
const projectIds = normalizeBulkProjectIds(input.projectIds);
let movedCount = 0;
// The board bulk-move is single-pipeline (all cards share the board pipeline),
// but collect the affected pipeline ids so the caller can publish one live
// board change-event per pipeline touched.
const pipelineIds = new Set<string>();
for (const projectId of projectIds) {
const moved = await moveProjectStage({
tenantId: input.tenantId,
userId: input.userId,
ctx: input.ctx,
projectId,
targetStageId: input.targetStageId,
lostReasonId: input.lostReasonId ?? null,
refreshAggregates: false,
});
pipelineIds.add(moved.pipelineId);
movedCount += 1;
}
await refreshPipelineAggregatesAfterMutation(input.tenantId, [...pipelineIds]);
return { movedCount, pipelineIds: [...pipelineIds] };
}
export const PROJECT_BULK_MOVE_MAX_ENTRIES = 100;
export function normalizeBulkProjectIds(projectIds: readonly string[]): string[] {
if (projectIds.length > PROJECT_BULK_MOVE_MAX_ENTRIES) {
throw new CatalogError(massActionErrors.exceedsLimit, {
affected: projectIds.length,
hardLimit: PROJECT_BULK_MOVE_MAX_ENTRIES,
});
}
return [...new Set(projectIds.map((id) => id.trim()).filter(Boolean))];
}
export async function undoLastProjectStageChange(input: {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
}): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.change_stage")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const restored = await withTenantRLS(input.tenantId, async (tx) => {
const now = new Date();
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectEditScopeWhere(input.tenantId, input.ctx),
},
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
await assertKwpProjectMutationAllowed({
tenantId: input.tenantId,
projectId: project.id,
operation: "stage",
client: tx,
});
const change = await tx.projektStageChange.findFirst({
where: {
tenantId: input.tenantId,
projektId: project.id,
toStageId: project.stageId,
undoneAt: null,
undoUntil: { gte: now },
},
orderBy: { createdAt: "desc" },
});
if (!change) throw new ProjectServiceError("projects.error.undoUnavailable");
const previousStage = await tx.pipelineStage.findFirst({
where: { id: change.fromStageId, tenantId: input.tenantId, pipelineId: project.pipelineId, archivedAt: null },
});
if (!previousStage) throw new CatalogError(foundationErrors.notFound, { stageId: change.fromStageId });
const updated = await tx.projekt.update({
where: { id: project.id, tenantId: input.tenantId },
data: {
stageId: previousStage.id,
wonAt: previousStage.stageKind === "gewonnen" ? project.wonAt : null,
lostAt: previousStage.stageKind === "verloren" ? project.lostAt : null,
lostReasonId: previousStage.stageKind === "verloren" ? project.lostReasonId : null,
undoStageChangeUntil: null,
// WF-P8 M03 "Wiederholen": remember where the undo came from so the
// board can offer redo without reading the stage history per card.
redoStageId: project.stageId,
redoLostReasonId: project.lostReasonId,
},
});
await tx.projektStageChange.update({
where: { id: change.id, tenantId: input.tenantId },
data: { undoneAt: now },
});
const reverseStageChange = await tx.projektStageChange.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
fromStageId: project.stageId,
toStageId: previousStage.id,
changedByUserId: input.userId,
undoUntil: null,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_stage_changed",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.stageChangeUndone",
metadata: { undoneChangeId: change.id, fromStageId: change.toStageId, toStageId: change.fromStageId },
},
{ client: tx }
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.stage_change_undone",
data: { ...workflowPayload(updated as ProjektRow), undoneChangeId: change.id, restoredStageId: previousStage.id },
userId: input.userId,
sourceId: `project:${project.id}:undo:${change.id}`,
})
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.stage_changed",
data: {
...workflowPayload(updated as ProjektRow),
fromStageId: project.stageId,
toStageId: previousStage.id,
stageKind: previousStage.stageKind,
reversedStageChangeId: change.id,
},
userId: input.userId,
sourceId: `project:${project.id}:stage-reverse:${reverseStageChange.id}`,
})
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildPortalStatusMailOutboxEvent(
{
tenantId: input.tenantId,
projectId: project.id,
previousStageId: project.stageId,
expectedStageId: previousStage.id,
stageChangeId: reverseStageChange.id,
},
{ now }
)
);
await publishProjectSearchUpsert(tx, updated as ProjektRow);
await ensureAndSyncProjectChannelIfEnabledWithTx(tx, {
tenantId: input.tenantId,
projectId: updated.id,
actorUserId: input.userId,
requestId: `project:${updated.id}:undo:${change.id}:chat-sync`,
reactivateArchived: previousStage.stageKind === "offen",
});
return updated as ProjektRow;
});
await refreshPipelineAggregatesAfterMutation(input.tenantId, [restored.pipelineId]);
return restored;
}
/**
* WF-P8 M03 "Wiederholen": re-apply the stage move a previous undo reverted.
*
* Deliberately a thin wrapper around moveProjectStage rather than a second
* write path: a redo is an ordinary move to a known stage, and every side
* effect that belongs to one (won/lost stamps, activity, workflow and portal
* events, chat sync, aggregates) is already attached there. The only thing
* this adds is where to move to, which the undo recorded on the project.
*
* The redo pointer is cleared by any forward move, so the offer disappears as
* soon as something else happened to the project.
*/
export async function redoLastProjectStageChange(input: {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
}): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.change_stage")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const target = 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: { id: true, redoStageId: true, redoLostReasonId: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
if (!project.redoStageId) throw new ProjectServiceError("projects.error.redoUnavailable");
return { stageId: project.redoStageId, lostReasonId: project.redoLostReasonId };
});
return moveProjectStage({
tenantId: input.tenantId,
projectId: input.projectId,
targetStageId: target.stageId,
userId: input.userId,
ctx: input.ctx,
lostReasonId: target.lostReasonId,
});
}
/**
* WF-P8 M03 "Board wechseln": move a project into another board.
*
* The project lands in the first open column of the target board — a board
* change restarts the run through that board's columns, so carrying a won or
* lost outcome over would claim an outcome the new board never produced.
*
* Custom field values survive: CustomFieldValue is keyed by definition and
* project, not by the board's field template, so the target board simply shows
* the subset its template contains and nothing is deleted.
*
* Deliberately writes no undoable stage change. Undo resolves its previous
* stage inside the project's *current* pipeline, so an undo offered after a
* board change would look for a column that is no longer in scope.
*/
export async function moveProjectToPipeline(input: {
tenantId: string;
projectId: string;
targetPipelineId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
}): Promise<{ project: ProjektRow; previousPipelineId: string }> {
if (!hasPermission(input.ctx, "projects.change_stage")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const result = 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),
},
include: { stage: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
if (project.pipelineId === input.targetPipelineId) {
throw new ProjectServiceError("projects.error.alreadyInPipeline");
}
await assertKwpProjectMutationAllowed({
tenantId: input.tenantId,
projectId: project.id,
operation: "stage",
client: tx,
});
// The target board has to be one the actor may see; otherwise a move would
// be a way to push work into a board they have no access to.
const targetPipeline = await tx.pipeline.findFirst({
where: {
id: input.targetPipelineId,
tenantId: input.tenantId,
archivedAt: null,
...pipelineVisibilityScopeWhere(input.tenantId, input.ctx),
},
select: { id: true, name: true },
});
if (!targetPipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.targetPipelineId });
const targetStage = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId: targetPipeline.id, archivedAt: null, stageKind: "offen" },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
if (!targetStage) throw new ProjectServiceError("projects.error.targetPipelineHasNoOpenStage");
const updated = await tx.projekt.update({
where: { id: project.id, tenantId: input.tenantId },
data: {
pipelineId: targetPipeline.id,
stageId: targetStage.id,
wonAt: null,
lostAt: null,
lostReasonId: null,
undoStageChangeUntil: null,
redoStageId: null,
redoLostReasonId: null,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.pipelineMoved",
metadata: {
fromPipelineId: project.pipelineId,
toPipelineId: targetPipeline.id,
fromStageId: project.stageId,
toStageId: targetStage.id,
},
},
{ client: tx }
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.pipeline_changed",
data: {
...workflowPayload(updated as ProjektRow),
fromPipelineId: project.pipelineId,
toPipelineId: targetPipeline.id,
toStageId: targetStage.id,
},
userId: input.userId,
sourceId: `project:${project.id}:pipeline:${targetPipeline.id}`,
})
);
await publishProjectSearchUpsert(tx, updated as ProjektRow);
await ensureAndSyncProjectChannelIfEnabledWithTx(tx, {
tenantId: input.tenantId,
projectId: updated.id,
actorUserId: input.userId,
requestId: `project:${updated.id}:pipeline:${targetPipeline.id}:chat-sync`,
reactivateArchived: true,
});
return { project: updated as ProjektRow, previousPipelineId: project.pipelineId };
});
// Both boards change their counts and forecast, so both caches are stale.
await refreshPipelineAggregatesAfterMutation(input.tenantId, [result.previousPipelineId, result.project.pipelineId]);
return result;
}
// --- R-P8b (RA-C2) cross-pipeline bulk archive (list view) -----------------------
// Archiving is the one project mutation with clean cross-pipeline semantics: a
// stage/status change is pipeline-bound (stages belong to a specific pipeline),
// but archive applies uniformly to any project regardless of its pipeline. Each
// project is validated against its OWN pipeline's edit scope (projectEditScopeWhere
// honours PipelineAccess per project), so a selection spanning several pipelines is
// handled correctly, project by project. Gated by `projects.delete`.
export async function archiveProject(input: {
tenantId: string;
projectId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
/** Internal: bulk callers refresh the aggregate cache once at the end instead of per project. */
refreshAggregates?: boolean;
}): Promise<ProjektRow> {
if (!hasPermission(input.ctx, "projects.delete")) {
throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
const archived = await withTenantRLS(input.tenantId, async (tx) => {
const now = new Date();
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...projectEditScopeWhere(input.tenantId, input.ctx),
},
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
await assertKwpProjectMutationAllowed({
tenantId: input.tenantId,
projectId: project.id,
operation: "archive",
client: tx,
});
const updated = await tx.projekt.update({
where: { id: project.id, tenantId: input.tenantId },
data: { archivedAt: now },
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_updated",
subjectType: "project",
subjectId: project.id,
summaryText: "activity.runtime.projects.archived",
metadata: { archived: true },
},
{ client: tx }
);
// Re-project into search: publishProjectSearchUpsert stamps `archived` from
// archivedAt, so the row drops out of active search results.
await publishProjectSearchUpsert(tx, updated as ProjektRow);
return updated as ProjektRow;
});
if (input.refreshAggregates !== false) {
await refreshPipelineAggregatesAfterMutation(input.tenantId, [archived.pipelineId]);
}
return archived;
}
export async function bulkArchiveProjects(input: {
tenantId: string;
userId: string | null;
ctx: ProjectVisibilityContext;
projectIds: readonly string[];
}): Promise<{ archivedCount: number; pipelineIds: string[] }> {
const projectIds = normalizeBulkProjectIds(input.projectIds);
let archivedCount = 0;
const pipelineIds = new Set<string>();
for (const projectId of projectIds) {
const archived = await archiveProject({
tenantId: input.tenantId,
userId: input.userId,
ctx: input.ctx,
projectId,
refreshAggregates: false,
});
pipelineIds.add(archived.pipelineId);
archivedCount += 1;
}
await refreshPipelineAggregatesAfterMutation(input.tenantId, [...pipelineIds]);
return { archivedCount, pipelineIds: [...pipelineIds] };
}