Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/projects/auftragService.server.ts
T
2026-07-08 16:15:37 +02:00

698 lines
26 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { logActivity } from "@hivegrid/activity";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import 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";
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 = {
userId: string;
permissions: readonly string[];
};
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;
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;
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 ProjectSubAreasVM = {
project: ProjektRow;
members: ProjektMitgliedRow[];
appointments: ProjektTerminRow[];
invoices: RechnungRow[];
checklists: ProjektChecklistRow[];
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, userId: input.ctx.userId, archivedAt: null },
select: { id: true },
});
return Boolean(membership);
}
async function requireProjectSubAreaReadWithTx(tx: TenantRlsClient, input: { tenantId: string; projectId: string; ctx: AuftragVisibilityContext }): Promise<ProjektRow> {
const project = await tx.projekt.findFirst({ where: { id: input.projectId, tenantId: input.tenantId, archivedAt: null } });
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;
}
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 requireProjectWithTx(tx: TenantRlsClient, input: { tenantId: string; projectId: string }): Promise<ProjektRow> {
const project = await tx.projekt.findFirst({ where: { id: input.projectId, tenantId: input.tenantId, archivedAt: null } });
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
return project as ProjektRow;
}
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));
}
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: true },
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: true,
},
});
return row as AcceptanceFormTemplateRow;
}
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: true },
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: true,
},
});
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 },
},
}),
]);
return {
project,
members: members as ProjektMitgliedRow[],
appointments: appointments as ProjektTerminRow[],
invoices: invoices as RechnungRow[],
checklists: checklists as ProjektChecklistRow[],
stageConfig: stageConfig as AuftragPipelineStageConfigRow | null,
acceptanceTemplates: acceptanceTemplates as AcceptanceFormTemplateRow[],
projectTemplates: projectTemplates as ProjectTemplateRow[],
openDefects,
};
});
}
export async function addProjectMember(input: {
tenantId: string;
projectId: string;
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 requireProjectWithTx(tx, input);
const row = await tx.projektMitglied.create({
data: {
tenantId: input.tenantId,
projektId: input.projectId,
createdByUserId: input.createdByUserId,
userId: normalizeText(input.userId),
groupId: normalizeText(input.groupId),
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: "Projektmitglied wurde hinzugefügt.",
metadata: { userId: input.userId ?? null, groupId: input.groupId ?? null, rolle: input.rolle },
},
{ client: tx },
);
return row as ProjektMitgliedRow;
});
}
export async function createProjectAppointment(input: {
tenantId: string;
projectId: string;
createdByUserId: string | null;
titel: string;
beschreibung?: string | null;
startAt: string | Date;
endAt?: string | Date | null;
assignedToUserId?: string | null;
assignedToGroupId?: string | null;
}): 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 requireProjectWithTx(tx, input);
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),
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.createdByUserId,
actorKind: input.createdByUserId ? "user" : "system",
activityType: "project_appointment_created",
subjectType: "project",
subjectId: input.projectId,
summaryText: "Projekttermin wurde angelegt.",
metadata: { appointmentId: row.id, startAt: row.startAt.toISOString() },
},
{ client: tx },
);
return row as ProjektTerminRow;
});
}
export async function createProjectInvoice(input: {
tenantId: string;
projectId: string;
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 requireProjectWithTx(tx, input);
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: `Rechnung ${row.rechnungsnummer} wurde angelegt.`,
metadata: { invoiceId: row.id, rechnungsnummer: row.rechnungsnummer },
},
{ client: tx },
);
return row as RechnungRow;
});
}
export async function updateProjectInvoiceStatus(input: { tenantId: string; invoiceId: string; 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 });
const row = await tx.rechnung.update({ where: { id: input.invoiceId }, data: { status: input.status } });
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_invoice_status_changed",
subjectType: "project",
subjectId: existing.projektId,
summaryText: `Rechnungsstatus wurde auf ${input.status} gesetzt.`,
metadata: { invoiceId: input.invoiceId, status: input.status },
},
{ client: tx },
);
return row as RechnungRow;
});
}
export async function createProjectChecklist(input: {
tenantId: string;
projectId: string;
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 requireProjectWithTx(tx, input);
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: "Projekt-Checkliste wurde angelegt.",
metadata: { checklistId: row.id, typ: row.typ },
},
{ client: tx },
);
return row as ProjektChecklistRow;
});
}
export async function upsertProjectChecklistItem(input: {
tenantId: string;
checklistId: string;
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 });
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 row = await tx.projektChecklistItem.update({ where: { id: input.itemId }, 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 row = await tx.auftragPipelineStageConfig.upsert({
where: { tenantId_stageId: { tenantId: input.tenantId, stageId: input.stageId } },
update: {
requiresAcceptanceProtocol: input.requiresAcceptanceProtocol ?? false,
acceptanceFormTemplateId: normalizeText(input.acceptanceFormTemplateId),
autoArchiveAfterDays: input.autoArchiveAfterDays ?? null,
},
create: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
stageId: input.stageId,
requiresAcceptanceProtocol: input.requiresAcceptanceProtocol ?? false,
acceptanceFormTemplateId: normalizeText(input.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 row = await tx.acceptanceFormTemplate.update({
where: { id: input.templateId },
data: { name, formId: normalizeText(input.formId), definition: input.definition ?? {}, isDefault: input.isDefault ?? false, 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 data = {
name,
defaultPipelineId: normalizeText(input.defaultPipelineId),
defaultStageId: normalizeText(input.defaultStageId),
defaultMembers: input.defaultMembers ?? [],
defaultTasks: input.defaultTasks ?? [],
customFields: input.customFields ?? {},
archivedAt: null,
};
if (input.templateId) {
const row = await tx.projectTemplate.update({ where: { id: input.templateId }, 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[];
});
}
export async function autoArchiveProjectsForStageConfigs(input: { tenantId: string; now?: Date; limit?: number }): Promise<{ archived: number }> {
const now = input.now ?? new Date();
return withTenantRLS(input.tenantId, async (tx) => {
const configs = await tx.auftragPipelineStageConfig.findMany({
where: { tenantId: input.tenantId, autoArchiveAfterDays: { not: null } },
take: Math.min(Math.max(input.limit ?? 100, 1), 500),
});
let archived = 0;
for (const config of configs) {
if (!config.autoArchiveAfterDays) continue;
const threshold = new Date(now.getTime() - config.autoArchiveAfterDays * 24 * 60 * 60 * 1000);
const result = await tx.projekt.updateMany({
where: {
tenantId: input.tenantId,
stageId: config.stageId,
archivedAt: null,
updatedAt: { lte: threshold },
},
data: { archivedAt: now },
});
archived += result.count;
}
return { archived };
});
}