import { randomUUID } from "node:crypto"; import { CatalogError, foundationErrors } from "@hivegrid/foundation"; import { logActivity } from "@hivegrid/activity"; import { archiveProjectChannelIfEnabledWithTx, ensureAndSyncProjectChannelIfEnabledWithTx } from "@hivegrid/chat/project-sync.server"; import { autoArchiveProjectsForTenantWithTx } from "@hivegrid/db/project-auto-archive.server"; import { lockPermissionGroupsWithTx } from "@hivegrid/chat/sync-locks.server"; import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server"; import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus"; import { fileReadScopeWhereWithTx } from "../fileVisibility.server"; import { projectEditScopeWhere, projectReadScopeWhere, type ProjectVisibilityContext, type ProjektRow, } from "./projectService.server"; export class AuftragServiceError extends Error { readonly messageKey: string; constructor(messageKey: string) { super(messageKey); this.name = "AuftragServiceError"; this.messageKey = messageKey; } } export type ProjektMitgliedRolle = "monteur" | "projektleiter" | "zustaendig" | "beobachter"; export type ProjektTerminStatus = "geplant" | "bestaetigt" | "erledigt" | "abgesagt"; /** WF-P8 S02-T appointment kind. */ export type ProjektTerminTyp = "vor_ort" | "telefon" | "online"; export type RechnungStatus = "entwurf" | "gestellt" | "bezahlt" | "storniert"; export type RechnungQuelle = "manuell" | "kwp"; export type ProjektChecklistTyp = "generisch" | "maengel" | "abnahme_doku"; export type ProjektChecklistItemStatus = "offen" | "erledigt" | "entfaellt"; export type AuftragVisibilityContext = ProjectVisibilityContext; export type ProjektMitgliedRow = { id: string; tenantId: string; projektId: string; userId: string | null; groupId: string | null; rolle: ProjektMitgliedRolle; createdByUserId: string | null; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; export type ProjektTerminRow = { id: string; tenantId: string; projektId: string; createdByUserId: string | null; assignedToUserId: string | null; assignedToGroupId: string | null; titel: string; beschreibung: string | null; startAt: Date; endAt: Date | null; status: ProjektTerminStatus; typ: ProjektTerminTyp; isDemoData: boolean; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; export type RechnungRow = { id: string; tenantId: string; projektId: string; createdByUserId: string | null; rechnungsnummer: string; betrag: unknown; status: RechnungStatus; datum: Date; faelligAm: Date | null; /** Null for invoices marked paid before the field existed — never treat null as "today". */ bezahltAm: Date | null; dateiId: string | null; quelle: RechnungQuelle; isDemoData: boolean; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; export type ProjektChecklistItemRow = { id: string; tenantId: string; checklistId: string; titel: string; status: ProjektChecklistItemStatus; maengelKlasse: string | null; fotoFileId: string | null; assignedToUserId: string | null; dueAt: Date | null; position: number; createdAt: Date; updatedAt: Date; }; export type ProjektChecklistRow = { id: string; tenantId: string; projektId: string; createdByUserId: string | null; titel: string; typ: ProjektChecklistTyp; isDemoData: boolean; createdAt: Date; updatedAt: Date; archivedAt: Date | null; items?: ProjektChecklistItemRow[]; }; export type AuftragPipelineStageConfigRow = { id: string; tenantId: string; pipelineId: string; stageId: string; requiresAcceptanceProtocol: boolean; acceptanceFormTemplateId: string | null; autoArchiveAfterDays: number | null; createdAt: Date; updatedAt: Date; }; export type AcceptanceFormTemplateRow = { id: string; tenantId: string; name: string; formId: string | null; definition: unknown; isDefault: boolean; isDemoData: boolean; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; export type ProjectTemplateRow = { id: string; tenantId: string; name: string; defaultPipelineId: string | null; defaultStageId: string | null; defaultMembers: unknown; defaultTasks: unknown; customFields: unknown; isDemoData: boolean; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; export type ProjectSubAreaTaskRow = { id: string; title: string; status: string; priority: string; deadline: Date | null; assignedToUserId: string | null; assignedToGroupId: string | null; }; export type ProjectSubAreaDocumentRow = { id: string; displayName: string; mimeType: string; sizeBytes: number; linkRole: string | null; createdAt: Date; }; export type ProjectSubAreaMaterialRow = { id: string; positionNumber: string | null; text: string; longText: string | null; quantity: unknown; unit: string | null; unitPrice: unknown; totalPrice: unknown; lastSyncedAt: Date; }; export type ProjectSubAreasVM = { project: ProjektRow; members: ProjektMitgliedRow[]; appointments: ProjektTerminRow[]; invoices: RechnungRow[]; checklists: ProjektChecklistRow[]; tasks: ProjectSubAreaTaskRow[]; documents: ProjectSubAreaDocumentRow[]; materials: ProjectSubAreaMaterialRow[]; groupOptions: { id: string; name: string }[]; stageConfig: AuftragPipelineStageConfigRow | null; acceptanceTemplates: AcceptanceFormTemplateRow[]; projectTemplates: ProjectTemplateRow[]; openDefects: number; }; function normalizeText(value?: string | null): string | null { const normalized = value?.trim(); return normalized || null; } function parseDate(value?: string | Date | null): Date | null { if (!value) return null; if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function decimalToString(value: unknown): string { if (value == null) return "0.00"; if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(2) : "0.00"; if (typeof value === "string") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed.toFixed(2) : "0.00"; } if (typeof value === "object" && "toString" in value && typeof value.toString === "function") { const parsed = Number(value.toString()); return Number.isFinite(parsed) ? parsed.toFixed(2) : "0.00"; } return "0.00"; } function hasPermission(ctx: AuftragVisibilityContext | undefined, permission: string): boolean { return ctx?.permissions.includes(permission) ?? false; } async function canReadProjectSubAreasWithTx( tx: TenantRlsClient, input: { tenantId: string; project: ProjektRow; ctx: AuftragVisibilityContext } ): Promise { if (hasPermission(input.ctx, "projects.read_all")) return true; if (hasPermission(input.ctx, "projects.read_own_pl") && input.project.projektleiterId === input.ctx.userId) return true; if (hasPermission(input.ctx, "projects.read_own") && input.project.zustaendigePersonId === input.ctx.userId) return true; if (!hasPermission(input.ctx, "projects.read_own_member")) return false; const membership = await tx.projektMitglied.findFirst({ where: { tenantId: input.tenantId, projektId: input.project.id, archivedAt: null, OR: [ { userId: input.ctx.userId }, ...((input.ctx.groupIds?.length ?? 0) > 0 ? [{ groupId: { in: [...(input.ctx.groupIds ?? [])] } }] : []), ], }, select: { id: true }, }); return Boolean(membership); } async function requireProjectSubAreaAccessWithTx( tx: TenantRlsClient, input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext; editable: boolean } ): Promise { const project = await tx.projekt.findFirst({ where: { id: input.projectId, tenantId: input.tenantId, archivedAt: null, ...(input.editable ? projectEditScopeWhere(input.tenantId, input.ctx) : projectReadScopeWhere(input.tenantId, input.ctx)), }, }); if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId }); if (!(await canReadProjectSubAreasWithTx(tx, { tenantId: input.tenantId, project: project as ProjektRow, ctx: input.ctx }))) { throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId }); } return project as ProjektRow; } async function requireProjectSubAreaReadWithTx( tx: TenantRlsClient, input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext } ): Promise { return requireProjectSubAreaAccessWithTx(tx, { ...input, editable: false }); } function requireExactlyOnePrincipal(userId?: string | null, groupId?: string | null): void { const hasUser = Boolean(normalizeText(userId)); const hasGroup = Boolean(normalizeText(groupId)); if (hasUser === hasGroup) throw new AuftragServiceError("projects.error.memberPrincipalRequired"); } function invoiceNumber(prefix: string, nextNumber: number, padding: number): string { return `${prefix}${String(nextNumber).padStart(padding, "0")}`; } async function assertTenantUserWithTx(tx: TenantRlsClient, tenantId: string, userId?: string | null): Promise { const normalized = normalizeText(userId); if (!normalized) return; const membership = await tx.tenantUser.findFirst({ where: { tenantId, userId: normalized, status: TenantUserStatus.ACTIVE, user: { active: true } }, select: { userId: true }, }); if (!membership) throw new CatalogError(foundationErrors.notFound, { userId: normalized }); } async function assertTenantGroupWithTx(tx: TenantRlsClient, tenantId: string, groupId?: string | null): Promise { const normalized = normalizeText(groupId); if (!normalized) return; const group = await tx.group.findFirst({ where: { id: normalized, tenantId }, select: { id: true } }); if (!group) throw new CatalogError(foundationErrors.notFound, { groupId: normalized }); } async function assertVisibleTenantFileWithTx( tx: TenantRlsClient, tenantId: string, fileId: string | null | undefined, ctx: AuftragVisibilityContext, ): Promise { const normalized = normalizeText(fileId); if (!normalized) return; const visibility = await fileReadScopeWhereWithTx(tx, tenantId, ctx, [normalized]); const file = await tx.file.findFirst({ where: { id: normalized, tenantId, archivedAt: null, ...visibility }, select: { id: true }, }); if (!file) throw new CatalogError(foundationErrors.notFound, { fileId: normalized }); } async function reserveInvoiceNumberWithTx(tx: TenantRlsClient, tenantId: string): Promise { const sequenceId = `seq_${randomUUID()}`; await tx.$executeRaw` INSERT INTO "HiveNumberSeq" ("id", "tenantId", "entityType", "prefix", "nextNumber", "padding", "createdAt", "updatedAt") VALUES (${sequenceId}, ${tenantId}, 'rechnung', 'R-', 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" = 'rechnung' FOR UPDATE `; const current = rows[0]; if (!current) throw new AuftragServiceError("projects.error.invoiceNumberSequenceMissing"); await tx.hiveNumberSeq.update({ where: { tenantId_entityType: { tenantId, entityType: "rechnung" } }, data: { nextNumber: current.nextNumber + 1 }, }); return invoiceNumber(current.prefix || "R-", current.nextNumber, current.padding); } export async function ensureDefaultAcceptanceFormTemplate(input: { tenantId: string; userId?: string | null }): Promise { return withTenantRLS(input.tenantId, (tx) => ensureDefaultAcceptanceFormTemplateWithTx(tx, input.tenantId)); } export async function ensureDefaultProjectTemplate(input: { tenantId: string }): Promise { return withTenantRLS(input.tenantId, (tx) => ensureDefaultProjectTemplateWithTx(tx, input.tenantId)); } export async function ensureDefaultAcceptanceFormTemplateWithTx(tx: TenantRlsClient, tenantId: string): Promise { const row = await tx.acceptanceFormTemplate.upsert({ where: { tenantId_name: { tenantId, name: "SHK Standard-Abnahme" } }, update: { archivedAt: null, isDefault: true, isDemoData: false }, create: { tenantId, name: "SHK Standard-Abnahme", definition: { sections: [ { key: "leistungen", labelKey: "projects.defaults.acceptance.sections.leistungen" }, { key: "maengel", labelKey: "projects.defaults.acceptance.sections.maengel" }, { key: "unterschrift", labelKey: "projects.defaults.acceptance.sections.unterschrift" }, ], }, isDefault: true, isDemoData: false, }, }); return row as AcceptanceFormTemplateRow; } export async function ensureDefaultProjectTemplateWithTx(tx: TenantRlsClient, tenantId: string): Promise { const row = await tx.projectTemplate.upsert({ where: { tenantId_name: { tenantId, name: "SHK Standard-Projekt" } }, update: { archivedAt: null, isDemoData: false }, create: { tenantId, name: "SHK Standard-Projekt", defaultMembers: [], defaultTasks: [ { titleKey: "projects.defaults.tasks.prepareSite", priority: "normal" }, { titleKey: "projects.defaults.tasks.checkMaterial", priority: "normal" }, { titleKey: "projects.defaults.tasks.prepareAcceptance", priority: "high" }, ], customFields: {}, isDemoData: false, }, }); return row as ProjectTemplateRow; } export async function loadProjectSubAreas(input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const project = await requireProjectSubAreaReadWithTx(tx, input); await Promise.all([ensureDefaultAcceptanceFormTemplateWithTx(tx, input.tenantId), ensureDefaultProjectTemplateWithTx(tx, input.tenantId)]); const [members, appointments, invoices, checklists, stageConfig, acceptanceTemplates, projectTemplates, openDefects] = await Promise.all([ tx.projektMitglied.findMany({ where: { tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, orderBy: { createdAt: "asc" } }), tx.projektTermin.findMany({ where: { tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, orderBy: { startAt: "asc" } }), tx.rechnung.findMany({ where: { tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, orderBy: { createdAt: "desc" } }), tx.projektChecklist.findMany({ where: { tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, include: { items: { orderBy: { position: "asc" } } }, orderBy: { createdAt: "asc" }, }), tx.auftragPipelineStageConfig.findFirst({ where: { tenantId: input.tenantId, stageId: project.stageId } }), tx.acceptanceFormTemplate.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: [{ isDefault: "desc" }, { name: "asc" }] }), tx.projectTemplate.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: [{ isDemoData: "desc" }, { name: "asc" }] }), tx.projektChecklistItem.count({ where: { tenantId: input.tenantId, status: "offen", checklist: { projektId: input.projectId, typ: "maengel", archivedAt: null }, }, }), ]); // R-P9a DoD 2 — tasks + documents sub-areas. Tasks are the tasks-module // rows linked to this project; documents are the files linked through // FileEntityLink, filtered by file visibility. const [tasks, fileLinks, groups, materials] = await Promise.all([ tx.task.findMany({ where: { tenantId: input.tenantId, linkedEntityType: "project", linkedEntityId: input.projectId, archivedAt: null }, select: { id: true, title: true, status: true, priority: true, deadline: true, assignedToUserId: true, assignedToGroupId: true }, orderBy: { createdAt: "desc" }, take: 100, }), tx.fileEntityLink.findMany({ where: { tenantId: input.tenantId, entityType: "project", entityId: input.projectId }, select: { fileId: true, linkRole: true }, orderBy: { createdAt: "desc" }, take: 200, }), tx.group.findMany({ where: { tenantId: input.tenantId }, select: { id: true, name: true }, orderBy: { name: "asc" } }), tx.kwpMaterialPosition.findMany({ where: { tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, select: { id: true, positionNumber: true, text: true, longText: true, quantity: true, unit: true, unitPrice: true, totalPrice: true, lastSyncedAt: true, }, orderBy: [{ positionNumber: "asc" }, { externalId: "asc" }], take: 500, }), ]); let documents: ProjectSubAreaDocumentRow[] = []; if (fileLinks.length > 0) { const visibility = await fileReadScopeWhereWithTx( tx, input.tenantId, input.ctx, fileLinks.map((link) => link.fileId) ); const files = await tx.file.findMany({ where: { id: { in: fileLinks.map((link) => link.fileId) }, tenantId: input.tenantId, archivedAt: null, ...visibility }, select: { id: true, displayName: true, mimeType: true, sizeBytes: true, createdAt: true }, }); const linkRoleByFileId = new Map(fileLinks.map((link) => [link.fileId, link.linkRole])); documents = files.map((file) => ({ id: file.id, displayName: file.displayName, mimeType: file.mimeType, sizeBytes: file.sizeBytes, linkRole: linkRoleByFileId.get(file.id) ?? null, createdAt: file.createdAt, })); } return { project, members: members as ProjektMitgliedRow[], appointments: appointments as ProjektTerminRow[], invoices: invoices as RechnungRow[], checklists: checklists as ProjektChecklistRow[], tasks: tasks as ProjectSubAreaTaskRow[], documents, materials: materials as ProjectSubAreaMaterialRow[], groupOptions: groups, stageConfig: stageConfig as AuftragPipelineStageConfigRow | null, acceptanceTemplates: acceptanceTemplates as AcceptanceFormTemplateRow[], projectTemplates: projectTemplates as ProjectTemplateRow[], openDefects, }; }); } export async function addProjectMember(input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext; createdByUserId: string | null; userId?: string | null; groupId?: string | null; rolle: ProjektMitgliedRolle; }): Promise { requireExactlyOnePrincipal(input.userId, input.groupId); return withTenantRLS(input.tenantId, async (tx) => { await requireProjectSubAreaAccessWithTx(tx, { ...input, editable: true }); const normalizedGroupId = normalizeText(input.groupId); await lockPermissionGroupsWithTx(tx, input.tenantId, normalizedGroupId ? [normalizedGroupId] : []); await Promise.all([ assertTenantUserWithTx(tx, input.tenantId, input.userId), assertTenantGroupWithTx(tx, input.tenantId, normalizedGroupId), ]); const row = await tx.projektMitglied.create({ data: { tenantId: input.tenantId, projektId: input.projectId, createdByUserId: input.createdByUserId, userId: normalizeText(input.userId), groupId: normalizedGroupId, rolle: input.rolle, }, }); await logActivity( { tenantId: input.tenantId, actorUserId: input.createdByUserId, actorKind: input.createdByUserId ? "user" : "system", activityType: "project_member_added", subjectType: "project", subjectId: input.projectId, summaryText: "activity.runtime.projects.memberAdded", metadata: { userId: input.userId ?? null, groupId: input.groupId ?? null, rolle: input.rolle }, }, { client: tx } ); await ensureAndSyncProjectChannelIfEnabledWithTx(tx, { tenantId: input.tenantId, projectId: input.projectId, actorUserId: input.createdByUserId, requestId: `project:${input.projectId}:member:${row.id}:chat-add`, }); return row as ProjektMitgliedRow; }); } export async function removeProjectMember(input: { tenantId: string; projectId: string; memberId: string; ctx: AuftragVisibilityContext; removedByUserId: string | null; }): Promise<{ id: string }> { const memberId = normalizeText(input.memberId); if (!memberId) throw new CatalogError(foundationErrors.notFound, { memberId: input.memberId }); return withTenantRLS(input.tenantId, async (tx) => { await requireProjectSubAreaAccessWithTx(tx, { ...input, editable: true }); const member = await tx.projektMitglied.findFirst({ where: { id: memberId, tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, select: { id: true, userId: true, groupId: true, rolle: true }, }); if (!member) throw new CatalogError(foundationErrors.notFound, { memberId }); const archivedAt = new Date(); const archived = await tx.projektMitglied.updateMany({ where: { id: member.id, tenantId: input.tenantId, projektId: input.projectId, archivedAt: null }, data: { archivedAt }, }); if (archived.count !== 1) throw new CatalogError(foundationErrors.notFound, { memberId }); await logActivity( { tenantId: input.tenantId, actorUserId: input.removedByUserId, actorKind: input.removedByUserId ? "user" : "system", activityType: "project_member_removed", subjectType: "project", subjectId: input.projectId, summaryText: "activity.runtime.projects.memberRemoved", metadata: { memberId: member.id, userId: member.userId, groupId: member.groupId, rolle: member.rolle }, }, { client: tx } ); await ensureAndSyncProjectChannelIfEnabledWithTx(tx, { tenantId: input.tenantId, projectId: input.projectId, actorUserId: input.removedByUserId, requestId: `project:${input.projectId}:member:${member.id}:chat-remove`, }); return { id: member.id }; }); } export async function createProjectAppointment(input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext; createdByUserId: string | null; titel: string; beschreibung?: string | null; startAt: string | Date; endAt?: string | Date | null; assignedToUserId?: string | null; assignedToGroupId?: string | null; typ?: ProjektTerminTyp; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const title = normalizeText(input.titel); const startAt = parseDate(input.startAt); if (!title) throw new AuftragServiceError("projects.error.appointmentTitleRequired"); if (!startAt) throw new AuftragServiceError("projects.error.appointmentStartRequired"); await requireProjectSubAreaAccessWithTx(tx, { ...input, editable: true }); await Promise.all([ assertTenantUserWithTx(tx, input.tenantId, input.assignedToUserId), assertTenantGroupWithTx(tx, input.tenantId, input.assignedToGroupId), ]); const row = await tx.projektTermin.create({ data: { tenantId: input.tenantId, projektId: input.projectId, createdByUserId: input.createdByUserId, assignedToUserId: normalizeText(input.assignedToUserId), assignedToGroupId: normalizeText(input.assignedToGroupId), titel: title, beschreibung: normalizeText(input.beschreibung), startAt, endAt: parseDate(input.endAt), ...(input.typ ? { typ: input.typ } : {}), }, }); await logActivity( { tenantId: input.tenantId, actorUserId: input.createdByUserId, actorKind: input.createdByUserId ? "user" : "system", activityType: "project_appointment_created", subjectType: "project", subjectId: input.projectId, summaryText: "activity.runtime.projects.appointmentCreated", metadata: { appointmentId: row.id, startAt: row.startAt.toISOString() }, }, { client: tx } ); return row as ProjektTerminRow; }); } export async function createProjectInvoice(input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext; createdByUserId: string | null; betrag: string | number; datum?: string | Date | null; faelligAm?: string | Date | null; dateiId?: string | null; quelle?: RechnungQuelle; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { await requireProjectSubAreaAccessWithTx(tx, { ...input, editable: true }); await assertVisibleTenantFileWithTx(tx, input.tenantId, input.dateiId, input.ctx); const rechnungsnummer = await reserveInvoiceNumberWithTx(tx, input.tenantId); const row = await tx.rechnung.create({ data: { tenantId: input.tenantId, projektId: input.projectId, createdByUserId: input.createdByUserId, rechnungsnummer, betrag: decimalToString(input.betrag), datum: parseDate(input.datum) ?? new Date(), faelligAm: parseDate(input.faelligAm), dateiId: normalizeText(input.dateiId), quelle: input.quelle ?? "manuell", }, }); await logActivity( { tenantId: input.tenantId, actorUserId: input.createdByUserId, actorKind: input.createdByUserId ? "user" : "system", activityType: "project_invoice_created", subjectType: "project", subjectId: input.projectId, summaryText: "activity.runtime.projects.invoiceCreated", metadata: { invoiceId: row.id, rechnungsnummer: row.rechnungsnummer }, }, { client: tx } ); return row as RechnungRow; }); } export async function updateProjectInvoiceStatus(input: { tenantId: string; invoiceId: string; ctx: AuftragVisibilityContext; userId: string | null; status: RechnungStatus; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const existing = await tx.rechnung.findFirst({ where: { id: input.invoiceId, tenantId: input.tenantId, archivedAt: null } }); if (!existing) throw new CatalogError(foundationErrors.notFound, { invoiceId: input.invoiceId }); await requireProjectSubAreaAccessWithTx(tx, { tenantId: input.tenantId, projectId: existing.projektId, ctx: input.ctx, editable: true, }); // Stamp the payment date on the transition into `bezahlt` and clear it on the // way out, so revenue by payment date stays truthful when a payment is undone. // Re-setting an already-paid invoice to `bezahlt` keeps the original date. const bezahltAm = input.status === "bezahlt" ? (existing.bezahltAm ?? new Date()) : null; const row = await tx.rechnung.update({ where: { id: input.invoiceId, tenantId: input.tenantId }, data: { status: input.status, bezahltAm }, }); await logActivity( { tenantId: input.tenantId, actorUserId: input.userId, actorKind: input.userId ? "user" : "system", activityType: "project_invoice_status_changed", subjectType: "project", subjectId: existing.projektId, summaryText: "activity.runtime.projects.invoiceStatusChanged", metadata: { invoiceId: input.invoiceId, status: input.status }, }, { client: tx } ); return row as RechnungRow; }); } export async function createProjectChecklist(input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext; createdByUserId: string | null; titel: string; typ?: ProjektChecklistTyp; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const title = normalizeText(input.titel); if (!title) throw new AuftragServiceError("projects.error.checklistTitleRequired"); await requireProjectSubAreaAccessWithTx(tx, { ...input, editable: true }); const row = await tx.projektChecklist.create({ data: { tenantId: input.tenantId, projektId: input.projectId, createdByUserId: input.createdByUserId, titel: title, typ: input.typ ?? "generisch", }, }); await logActivity( { tenantId: input.tenantId, actorUserId: input.createdByUserId, actorKind: input.createdByUserId ? "user" : "system", activityType: "project_checklist_created", subjectType: "project", subjectId: input.projectId, summaryText: "activity.runtime.projects.checklistCreated", metadata: { checklistId: row.id, typ: row.typ }, }, { client: tx } ); return row as ProjektChecklistRow; }); } export async function upsertProjectChecklistItem(input: { tenantId: string; checklistId: string; ctx: AuftragVisibilityContext; itemId?: string | null; titel: string; status?: ProjektChecklistItemStatus; maengelKlasse?: string | null; fotoFileId?: string | null; assignedToUserId?: string | null; dueAt?: string | Date | null; position?: number; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const title = normalizeText(input.titel); if (!title) throw new AuftragServiceError("projects.error.checklistItemTitleRequired"); const checklist = await tx.projektChecklist.findFirst({ where: { id: input.checklistId, tenantId: input.tenantId, archivedAt: null } }); if (!checklist) throw new CatalogError(foundationErrors.notFound, { checklistId: input.checklistId }); await requireProjectSubAreaAccessWithTx(tx, { tenantId: input.tenantId, projectId: checklist.projektId, ctx: input.ctx, editable: true, }); await Promise.all([ assertTenantUserWithTx(tx, input.tenantId, input.assignedToUserId), assertVisibleTenantFileWithTx(tx, input.tenantId, input.fotoFileId, input.ctx), ]); const data = { titel: title, status: input.status ?? "offen", maengelKlasse: normalizeText(input.maengelKlasse), fotoFileId: normalizeText(input.fotoFileId), assignedToUserId: normalizeText(input.assignedToUserId), dueAt: parseDate(input.dueAt), position: input.position ?? 0, }; if (input.itemId) { const existingItem = await tx.projektChecklistItem.findFirst({ where: { id: input.itemId, tenantId: input.tenantId, checklistId: input.checklistId }, select: { id: true }, }); if (!existingItem) throw new CatalogError(foundationErrors.notFound, { itemId: input.itemId }); const row = await tx.projektChecklistItem.update({ where: { id: input.itemId, tenantId: input.tenantId, checklistId: input.checklistId }, data, }); return row as ProjektChecklistItemRow; } const row = await tx.projektChecklistItem.create({ data: { tenantId: input.tenantId, checklistId: input.checklistId, ...data }, }); return row as ProjektChecklistItemRow; }); } export async function upsertAuftragPipelineStageConfig(input: { tenantId: string; pipelineId: string; stageId: string; requiresAcceptanceProtocol?: boolean; acceptanceFormTemplateId?: string | null; autoArchiveAfterDays?: number | null; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const stage = await tx.pipelineStage.findFirst({ where: { id: input.stageId, tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null } }); if (!stage) throw new CatalogError(foundationErrors.notFound, { stageId: input.stageId }); const acceptanceFormTemplateId = normalizeText(input.acceptanceFormTemplateId); if (acceptanceFormTemplateId) { const template = await tx.acceptanceFormTemplate.findFirst({ where: { id: acceptanceFormTemplateId, tenantId: input.tenantId, archivedAt: null }, select: { id: true }, }); if (!template) throw new CatalogError(foundationErrors.notFound, { acceptanceFormTemplateId }); } const row = await tx.auftragPipelineStageConfig.upsert({ where: { tenantId_stageId: { tenantId: input.tenantId, stageId: input.stageId } }, update: { requiresAcceptanceProtocol: input.requiresAcceptanceProtocol ?? false, acceptanceFormTemplateId, autoArchiveAfterDays: input.autoArchiveAfterDays ?? null, }, create: { tenantId: input.tenantId, pipelineId: input.pipelineId, stageId: input.stageId, requiresAcceptanceProtocol: input.requiresAcceptanceProtocol ?? false, acceptanceFormTemplateId, autoArchiveAfterDays: input.autoArchiveAfterDays ?? null, }, }); return row as AuftragPipelineStageConfigRow; }); } export async function upsertAcceptanceFormTemplate(input: { tenantId: string; templateId?: string | null; name: string; formId?: string | null; definition?: unknown; isDefault?: boolean; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const name = normalizeText(input.name); if (!name) throw new AuftragServiceError("projects.error.acceptanceTemplateNameRequired"); if (input.templateId) { const existing = await tx.acceptanceFormTemplate.findFirst({ where: { id: input.templateId, tenantId: input.tenantId, archivedAt: null }, select: { id: true }, }); if (!existing) throw new CatalogError(foundationErrors.notFound, { templateId: input.templateId }); // Partial update: absent fields stay untouched (undefined = no-op in // Prisma) so e.g. a make-default toggle cannot wipe the definition. const row = await tx.acceptanceFormTemplate.update({ where: { id: existing.id, tenantId: input.tenantId }, data: { name, formId: input.formId === undefined ? undefined : normalizeText(input.formId), definition: input.definition === undefined ? undefined : input.definition ?? {}, isDefault: input.isDefault, archivedAt: null, }, }); return row as AcceptanceFormTemplateRow; } const row = await tx.acceptanceFormTemplate.upsert({ where: { tenantId_name: { tenantId: input.tenantId, name } }, update: { formId: normalizeText(input.formId), definition: input.definition ?? {}, isDefault: input.isDefault ?? false, archivedAt: null }, create: { tenantId: input.tenantId, name, formId: normalizeText(input.formId), definition: input.definition ?? {}, isDefault: input.isDefault ?? false }, }); return row as AcceptanceFormTemplateRow; }); } export async function listAcceptanceFormTemplates(input: { tenantId: string }): Promise { return withTenantRLS(input.tenantId, async (tx) => { await ensureDefaultAcceptanceFormTemplateWithTx(tx, input.tenantId); const rows = await tx.acceptanceFormTemplate.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); return rows as AcceptanceFormTemplateRow[]; }); } export async function upsertProjectTemplate(input: { tenantId: string; templateId?: string | null; name: string; defaultPipelineId?: string | null; defaultStageId?: string | null; defaultMembers?: unknown; defaultTasks?: unknown; customFields?: unknown; }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const name = normalizeText(input.name); if (!name) throw new AuftragServiceError("projects.error.projectTemplateNameRequired"); const defaultPipelineId = normalizeText(input.defaultPipelineId); const defaultStageId = normalizeText(input.defaultStageId); if (defaultStageId && !defaultPipelineId) throw new AuftragServiceError("projects.error.pipelineRequired"); if (defaultStageId && defaultPipelineId) { const stage = await tx.pipelineStage.findFirst({ where: { id: defaultStageId, tenantId: input.tenantId, pipelineId: defaultPipelineId, archivedAt: null, pipeline: { tenantId: input.tenantId, archivedAt: null }, }, select: { id: true }, }); if (!stage) throw new CatalogError(foundationErrors.notFound, { defaultPipelineId, defaultStageId }); } else if (defaultPipelineId) { const pipeline = await tx.pipeline.findFirst({ where: { id: defaultPipelineId, tenantId: input.tenantId, archivedAt: null }, select: { id: true }, }); if (!pipeline) throw new CatalogError(foundationErrors.notFound, { defaultPipelineId }); } const data = { name, defaultPipelineId, defaultStageId, defaultMembers: input.defaultMembers ?? [], defaultTasks: input.defaultTasks ?? [], customFields: input.customFields ?? {}, archivedAt: null, }; if (input.templateId) { const existing = await tx.projectTemplate.findFirst({ where: { id: input.templateId, tenantId: input.tenantId, archivedAt: null }, select: { id: true }, }); if (!existing) throw new CatalogError(foundationErrors.notFound, { templateId: input.templateId }); const row = await tx.projectTemplate.update({ where: { id: existing.id, tenantId: input.tenantId }, data }); return row as ProjectTemplateRow; } const row = await tx.projectTemplate.upsert({ where: { tenantId_name: { tenantId: input.tenantId, name } }, update: data, create: { tenantId: input.tenantId, ...data }, }); return row as ProjectTemplateRow; }); } export async function listProjectTemplates(input: { tenantId: string }): Promise { return withTenantRLS(input.tenantId, async (tx) => { await ensureDefaultProjectTemplateWithTx(tx, input.tenantId); const rows = await tx.projectTemplate.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: [{ isDemoData: "desc" }, { name: "asc" }], }); return rows as ProjectTemplateRow[]; }); } // R-P9a DoD 4 — the sweep logic is shared with the worker cron via // @hivegrid/db/project-auto-archive.server; this wrapper only binds the tenant // RLS context and injects the chat-channel archive side effect (AK-P15b). export async function autoArchiveProjectsForStageConfigs(input: { tenantId: string; now?: Date; limit?: number }): Promise<{ archived: number }> { return withTenantRLS(input.tenantId, (tx) => autoArchiveProjectsForTenantWithTx(tx as unknown as Parameters[0], { tenantId: input.tenantId, now: input.now, limit: input.limit, archiveChannel: (channelInput) => archiveProjectChannelIfEnabledWithTx(tx, channelInput), }) ); }