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

1005 lines
38 KiB
TypeScript

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<boolean> {
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<ProjektRow> {
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<ProjektRow> {
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<void> {
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<void> {
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<void> {
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<string> {
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<Array<{ nextNumber: number; padding: number; prefix: string }>>`
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<AcceptanceFormTemplateRow> {
return withTenantRLS(input.tenantId, (tx) => ensureDefaultAcceptanceFormTemplateWithTx(tx, input.tenantId));
}
export async function ensureDefaultProjectTemplate(input: { tenantId: string }): Promise<ProjectTemplateRow> {
return withTenantRLS(input.tenantId, (tx) => ensureDefaultProjectTemplateWithTx(tx, input.tenantId));
}
export async function ensureDefaultAcceptanceFormTemplateWithTx(tx: TenantRlsClient, tenantId: string): Promise<AcceptanceFormTemplateRow> {
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<ProjectTemplateRow> {
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<ProjectSubAreasVM> {
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<ProjektMitgliedRow> {
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<ProjektTerminRow> {
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<RechnungRow> {
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<RechnungRow> {
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<ProjektChecklistRow> {
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<ProjektChecklistItemRow> {
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<AuftragPipelineStageConfigRow> {
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<AcceptanceFormTemplateRow> {
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<AcceptanceFormTemplateRow[]> {
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<ProjectTemplateRow> {
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<ProjectTemplateRow[]> {
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<typeof autoArchiveProjectsForTenantWithTx>[0], {
tenantId: input.tenantId,
now: input.now,
limit: input.limit,
archiveChannel: (channelInput) => archiveProjectChannelIfEnabledWithTx(tx, channelInput),
})
);
}