Kundenportal Vertragsanfragen ergänzen
This commit is contained in:
3
backend/db/migrations/0035_contract_history.sql
Normal file
3
backend/db/migrations/0035_contract_history.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -239,6 +239,13 @@
|
||||
"when": 1777003200000,
|
||||
"tag": "0033_costcentres_parent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1778191200000,
|
||||
"tag": "0035_contract_history",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {files} from "./files";
|
||||
import { memberrelations } from "./memberrelations";
|
||||
import { contracts } from "./contracts";
|
||||
|
||||
export const historyitems = pgTable("historyitems", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -52,6 +53,11 @@ export const historyitems = pgTable("historyitems", {
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
contract: bigint("contract", { mode: "number" }).references(
|
||||
() => contracts.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
@@ -29,6 +29,7 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||
import userRoutes from "./routes/auth/user";
|
||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||
import wikiRoutes from "./routes/wiki";
|
||||
import portalContractRoutes from "./routes/portal/contracts";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -146,6 +147,7 @@ async function main() {
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
await subApp.register(resourceRoutes);
|
||||
await subApp.register(wikiRoutes);
|
||||
await subApp.register(portalContractRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
|
||||
|
||||
const columnMap: Record<string, any> = {
|
||||
customers: historyitems.customer,
|
||||
contracts: historyitems.contract,
|
||||
members: historyitems.customer,
|
||||
vendors: historyitems.vendor,
|
||||
projects: historyitems.project,
|
||||
@@ -30,6 +31,7 @@ const columnMap: Record<string, any> = {
|
||||
|
||||
const insertFieldMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
contracts: "contract",
|
||||
members: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
|
||||
221
backend/src/routes/portal/contracts.ts
Normal file
221
backend/src/routes/portal/contracts.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
|
||||
async function getPortalCustomerId(server: FastifyInstance, req: any) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId || !userId) return null
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.user_id, userId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return profile?.customer_for_portal || null
|
||||
}
|
||||
|
||||
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (!portalCustomerId) return null
|
||||
|
||||
const [contract] = await server.db
|
||||
.select({
|
||||
id: contracts.id,
|
||||
name: contracts.name,
|
||||
tenant: contracts.tenant,
|
||||
customer: contracts.customer,
|
||||
contracttype: contracts.contracttype,
|
||||
archived: contracts.archived,
|
||||
})
|
||||
.from(contracts)
|
||||
.where(and(
|
||||
eq(contracts.id, contractId),
|
||||
eq(contracts.tenant, req.user?.tenant_id),
|
||||
eq(contracts.customer, portalCustomerId),
|
||||
eq(contracts.archived, false)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return contract || null
|
||||
}
|
||||
|
||||
function normalizeMessage(message: unknown) {
|
||||
if (typeof message !== "string") return ""
|
||||
return message.trim()
|
||||
}
|
||||
|
||||
function appendMessage(text: string, message: string) {
|
||||
return message ? `${text} Nachricht: ${message}` : text
|
||||
}
|
||||
|
||||
function formatDateForHistory(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export default async function portalContractRoutes(server: FastifyInstance) {
|
||||
server.post<{
|
||||
Params: { id: string }
|
||||
Body: { contracttype?: number | string; message?: string }
|
||||
}>("/portal/contracts/:id/change-request", {
|
||||
schema: {
|
||||
tags: ["Portal"],
|
||||
summary: "Request contract type change from customer portal",
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["contracttype"],
|
||||
properties: {
|
||||
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
|
||||
message: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const contractId = Number(req.params.id)
|
||||
const requestedContracttypeId = Number(req.body.contracttype)
|
||||
|
||||
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
|
||||
return reply.code(400).send({ error: "Ungültige Anfrage" })
|
||||
}
|
||||
|
||||
const contract = await getPortalContract(server, req, contractId)
|
||||
if (!contract) {
|
||||
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
|
||||
}
|
||||
|
||||
const [requestedContracttype] = await server.db
|
||||
.select({
|
||||
id: contracttypes.id,
|
||||
name: contracttypes.name,
|
||||
})
|
||||
.from(contracttypes)
|
||||
.where(and(
|
||||
eq(contracttypes.id, requestedContracttypeId),
|
||||
eq(contracttypes.tenant, req.user?.tenant_id),
|
||||
eq(contracttypes.archived, false)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!requestedContracttype) {
|
||||
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
|
||||
}
|
||||
|
||||
const [currentContracttype] = contract.contracttype
|
||||
? await server.db
|
||||
.select({
|
||||
id: contracttypes.id,
|
||||
name: contracttypes.name,
|
||||
})
|
||||
.from(contracttypes)
|
||||
.where(and(
|
||||
eq(contracttypes.id, contract.contracttype),
|
||||
eq(contracttypes.tenant, req.user?.tenant_id)
|
||||
))
|
||||
.limit(1)
|
||||
: []
|
||||
|
||||
const message = normalizeMessage(req.body.message)
|
||||
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
|
||||
const newName = requestedContracttype.name
|
||||
const text = appendMessage(
|
||||
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
|
||||
message
|
||||
)
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: req.user?.tenant_id,
|
||||
created_by: req.user?.user_id || null,
|
||||
entity: "contracts",
|
||||
entityId: contract.id,
|
||||
action: "unchanged",
|
||||
oldVal: { contracttype: contract.contracttype, name: oldName },
|
||||
newVal: { contracttype: requestedContracttype.id, name: newName },
|
||||
text,
|
||||
})
|
||||
|
||||
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
|
||||
})
|
||||
|
||||
server.post<{
|
||||
Params: { id: string }
|
||||
Body: { requestedEndDate?: string; message?: string }
|
||||
}>("/portal/contracts/:id/cancellation-request", {
|
||||
schema: {
|
||||
tags: ["Portal"],
|
||||
summary: "Request contract cancellation from customer portal",
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["requestedEndDate"],
|
||||
properties: {
|
||||
requestedEndDate: { type: "string" },
|
||||
message: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const contractId = Number(req.params.id)
|
||||
const requestedEndDate = typeof req.body.requestedEndDate === "string"
|
||||
? req.body.requestedEndDate.trim()
|
||||
: ""
|
||||
|
||||
if (!Number.isInteger(contractId) || !requestedEndDate) {
|
||||
return reply.code(400).send({ error: "Ungültige Anfrage" })
|
||||
}
|
||||
|
||||
const parsedDate = new Date(requestedEndDate)
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
|
||||
}
|
||||
|
||||
const contract = await getPortalContract(server, req, contractId)
|
||||
if (!contract) {
|
||||
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
|
||||
}
|
||||
|
||||
const message = normalizeMessage(req.body.message)
|
||||
const text = appendMessage(
|
||||
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
|
||||
message
|
||||
)
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: req.user?.tenant_id,
|
||||
created_by: req.user?.user_id || null,
|
||||
entity: "contracts",
|
||||
entityId: contract.id,
|
||||
action: "unchanged",
|
||||
newVal: { requestedEndDate },
|
||||
text,
|
||||
})
|
||||
|
||||
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
|
||||
|
||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
customers: "Kunden",
|
||||
contracts: "Verträge",
|
||||
members: "Mitglieder",
|
||||
vendors: "Lieferanten",
|
||||
projects: "Projekte",
|
||||
@@ -63,6 +64,7 @@ export async function insertHistoryItem(
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
contracts: "contract",
|
||||
members: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
|
||||
Reference in New Issue
Block a user