1005 lines
38 KiB
TypeScript
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),
|
|
})
|
|
);
|
|
}
|