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; 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 { 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 { 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 { return { accessRules: { none: { tenantId, kannSehen: false, OR: effectivePipelinePrincipals(ctx), }, }, }; } function deniedPipelineScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record { return { pipeline: pipelineVisibilityScopeWhere(tenantId, ctx) }; } function pipelineEditabilityScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record { return { accessRules: { none: { tenantId, AND: [{ OR: effectivePipelinePrincipals(ctx) }, { OR: [{ kannSehen: false }, { kannBearbeiten: false }] }], }, }, }; } function editablePipelineScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record { return { pipeline: pipelineEditabilityScopeWhere(tenantId, ctx) }; } export function projectReadScopeWhere(tenantId: string, ctx: ProjectVisibilityContext): Record { 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 { 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; /** KPIs per responsible user (zustaendigePersonId) from the owner: 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 { 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 { 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 { 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 { 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, 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 { const filters = input.filters; const where: Record = { 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[] = []; 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 { 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 { if (input.customerTypes.length === 0) return null; const alternatives: Record[] = []; 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 { 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[]): 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 { 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 { 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 = {}; 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 { 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 { 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 { 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 { 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 { 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 { 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>` 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 { 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 { 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 { 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 { 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 { 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 { 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): { [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 " 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 { 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> { 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; }); } 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 { 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; /** Active projects per column — the S04 "Projekte" cell of a column row. */ byStage: ReadonlyMap; }; /** * 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 { 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 { 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 { 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 { 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 { 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 { 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(visibilities: ReadonlyArray>, 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, ctx: ProjectVisibilityContext | undefined ): Promise { 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, ctx: ProjectVisibilityContext | undefined ): Promise { return (await resolveProjectVolumeAccessWithTx(tx, project, ctx)).canView; } type ProjectVolumeVisibilityRule = { fieldKey: string; sichtbar: boolean; explicit: boolean | undefined; }; type ProjectVolumeVisibilityPolicy = Map; 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>; } ): Promise { 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, 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 { 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 { 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>; now: Date } ): Promise> { const empty: BoardCardCompanions = { kontaktLabel: null, ort: null, nextTermin: null, tags: [] }; const result = new Map(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(); 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(); for (const assignment of tagAssignments) { const list = tagsByProject.get(assignment.entityId) ?? []; list.push(assignment.tag); tagsByProject.set(assignment.entityId, list); } const nextTerminByProject = new Map(); 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> } ): Promise> { const result = new Map(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 { 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 { 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; stage: Pick; }; 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 { 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()), ]); 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 { 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 { 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 { 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 { 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 = {}; 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 = {}; 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:` 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; }; const ownerAggregates = new Map(); 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 ): Promise { 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 { // 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, 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 { 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 { 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 = {}; 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 = { 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; 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>; }; 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 { 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 { 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 { 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 { 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 = {}; 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 ): Promise> { 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 { 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(); 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 { 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 { 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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(); 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] }; }