4277 lines
169 KiB
TypeScript
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] };
|
|
}
|