diff --git a/modules/companies/src/api/application/company.repository.ts b/modules/companies/src/api/application/company.repository.ts index 9894a5a..bcfb711 100644 --- a/modules/companies/src/api/application/company.repository.ts +++ b/modules/companies/src/api/application/company.repository.ts @@ -12,4 +12,10 @@ export interface ICompanyRepository { id: UniqueID, change: (company: Company) => Result ): Promise>; + removePerson(id: UniqueID, personId: UniqueID): Promise>; + removeAssignment( + id: UniqueID, + personId: UniqueID, + assignmentId: UniqueID + ): Promise>; } diff --git a/modules/companies/src/api/application/company.use-cases.ts b/modules/companies/src/api/application/company.use-cases.ts index a185ccd..dea64ab 100644 --- a/modules/companies/src/api/application/company.use-cases.ts +++ b/modules/companies/src/api/application/company.use-cases.ts @@ -97,6 +97,14 @@ export class CompanyUseCases { company.updatePerson(child.data, props.data) ); } + async removePerson(companyId: string, id: string) { + const parent = this.input.id(companyId); + const child = this.input.id(id); + if (!(parent.isSuccess && child.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.removePerson(parent.data, child.data); + } async addDepartment(id: string, dto: DepartmentRequestDTO) { const parsed = this.input.id(id); const props = this.input.department(dto); @@ -148,6 +156,15 @@ export class CompanyUseCases { company.updateAssignment(person.data, child.data, values.data, new Date().toISOString()) ); } + async removeAssignment(companyId: string, personId: string, id: string) { + const parent = this.input.id(companyId); + const person = this.input.id(personId); + const assignment = this.input.id(id); + if (!(parent.isSuccess && person.isSuccess && assignment.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.removeAssignment(parent.data, person.data, assignment.data); + } async addSite(id: string, dto: SiteRequestDTO) { const parsed = this.input.id(id); const props = this.input.site(dto); diff --git a/modules/companies/src/api/domain/company-error.ts b/modules/companies/src/api/domain/company-error.ts index bf97fd9..9f4d2e4 100644 --- a/modules/companies/src/api/domain/company-error.ts +++ b/modules/companies/src/api/domain/company-error.ts @@ -4,9 +4,11 @@ export type CompanyErrorCode = | "CONTACT_NOT_FOUND" | "SITE_NOT_FOUND" | "PERSON_NOT_FOUND" + | "PERSON_IN_USE" | "DEPARTMENT_NOT_FOUND" | "DEPARTMENT_NAME_EXISTS" | "ASSIGNMENT_NOT_FOUND" + | "ASSIGNMENT_IN_USE" | "ASSIGNMENT_CONFLICT" | "INACTIVE_ASSIGNMENT_TARGET"; export class CompanyError extends Error { diff --git a/modules/companies/src/api/domain/company.aggregate.ts b/modules/companies/src/api/domain/company.aggregate.ts index 5f8fb2d..90159d2 100644 --- a/modules/companies/src/api/domain/company.aggregate.ts +++ b/modules/companies/src/api/domain/company.aggregate.ts @@ -109,6 +109,20 @@ export class Company extends AggregateRoot { ) ); } + removePerson(id: UniqueID): Result { + if (!this.people.some((person) => person.id.equals(id))) { + return Result.fail(new CompanyError("PERSON_NOT_FOUND")); + } + if (this.assignments.some((assignment) => assignment.personId.equals(id))) { + return Result.fail(new CompanyError("PERSON_IN_USE")); + } + return Result.ok( + new Company( + { ...this.props, people: this.people.filter((person) => !person.id.equals(id)) }, + this.id + ) + ); + } private departmentNameExists(name: string, except?: UniqueID): boolean { const key = name.normalize("NFC").toLowerCase(); return this.departments.some( @@ -256,6 +270,20 @@ export class Company extends AggregateRoot { ) ); } + removeAssignment(personId: UniqueID, id: UniqueID): Result { + const assignment = this.assignments.find( + (item) => item.id.equals(id) && item.personId.equals(personId) + ); + if (!assignment) { + return Result.fail(new CompanyError("ASSIGNMENT_NOT_FOUND")); + } + return Result.ok( + new Company( + { ...this.props, assignments: this.assignments.filter((item) => !item.id.equals(id)) }, + this.id + ) + ); + } addSite(site: Site): Result { if (!site.companyId.equals(this.id)) { return Result.fail(new CompanyError("INVALID_REQUEST")); diff --git a/modules/companies/src/api/infrastructure/http/company.routes.ts b/modules/companies/src/api/infrastructure/http/company.routes.ts index e79d91c..7fc64c7 100644 --- a/modules/companies/src/api/infrastructure/http/company.routes.ts +++ b/modules/companies/src/api/infrastructure/http/company.routes.ts @@ -126,6 +126,17 @@ export function registerCompanyRoutes( post("/:companyId/people/:id/update", PersonRequestSchema, ChildParamsSchema, (dto, params) => useCases.updatePerson(params.companyId, params.id, dto) ); + router.post("/:companyId/people/:id/delete", (request, response, next) => { + const params = ChildParamsSchema.safeParse(request.params); + if (!(params.success && z.strictObject({}).safeParse(request.body).success)) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + useCases + .removePerson(params.data.companyId, params.data.id) + .then((result) => sendResult(response, result)) + .catch(next); + }); post( "/:companyId/departments", DepartmentRequestSchema, @@ -146,6 +157,17 @@ export function registerCompanyRoutes( (dto, params) => useCases.addAssignment(params.companyId, params.personId, dto), 201 ); + router.post("/:companyId/people/:personId/assignments/:id/delete", (request, response, next) => { + const params = AssignmentParamsSchema.safeParse(request.params); + if (!(params.success && z.strictObject({}).safeParse(request.body).success)) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + useCases + .removeAssignment(params.data.companyId, params.data.personId, params.data.id) + .then((result) => sendResult(response, result)) + .catch(next); + }); post( "/:companyId/people/:personId/assignments/:id/update", AssignmentUpdateRequestSchema, diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts index aedbfc7..4a40e81 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts @@ -102,6 +102,55 @@ export class SequelizeCompanyRepository implements ICompanyRepository { await CompanyModel.create(this.mapper.toPersistence(company).company); return company; } + async removePerson( + id: UniqueID, + personId: UniqueID + ): Promise> { + return this.database.transaction(async (transaction) => { + const current = (await this.load(id, transaction, true)).getOrUndefined(); + if (!current) { + return Result.fail(new CompanyError("COMPANY_NOT_FOUND")); + } + const changed = current.removePerson(personId); + if (!changed.isSuccess) { + return changed; + } + try { + await PersonModel.destroy({ + where: { id: personId.toPrimitive(), companyId: id.toPrimitive() }, + transaction, + }); + } catch { + return Result.fail(new CompanyError("PERSON_IN_USE")); + } + return changed; + }); + } + async removeAssignment( + id: UniqueID, + personId: UniqueID, + assignmentId: UniqueID + ): Promise> { + return this.database.transaction(async (transaction) => { + const current = (await this.load(id, transaction, true)).getOrUndefined(); + if (!current) { + return Result.fail(new CompanyError("COMPANY_NOT_FOUND")); + } + const changed = current.removeAssignment(personId, assignmentId); + if (!changed.isSuccess) { + return changed; + } + await AssignmentRevisionModel.destroy({ + where: { assignmentId: assignmentId.toPrimitive() }, + transaction, + }); + await AssignmentModel.destroy({ + where: { id: assignmentId.toPrimitive(), companyId: id.toPrimitive() }, + transaction, + }); + return changed; + }); + } async update( id: UniqueID, change: (company: Company) => DomainResult diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 1912e55..f8e4242 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -909,7 +909,15 @@ "removed": "The logo will be removed on save.", "remove": "Remove logo", "undo": "Undo logo change" - } + }, + "deletePerson": "Delete person", + "deletingPerson": "Deleting…", + "deletePersonConfirm": "Delete {{name}}? It can only be deleted when it has no registered relationships.", + "deletePersonError": "This person cannot be deleted because it has registered relationships.", + "deleteAssignment": "Delete assignment", + "deletingAssignment": "Deleting…", + "deleteAssignmentConfirm": "Delete the assignment to {{name}}?", + "deleteAssignmentError": "The assignment could not be deleted." }, "access": { "helpTitle": "What is it and what is it for?", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index c13f19c..543bfe4 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -909,7 +909,15 @@ "removed": "Se quitará el logotipo al guardar.", "remove": "Quitar logotipo", "undo": "Deshacer cambio de logotipo" - } + }, + "deletePerson": "Eliminar persona", + "deletingPerson": "Eliminando…", + "deletePersonConfirm": "¿Eliminar a {{name}}? Solo se puede eliminar si no tiene relaciones registradas.", + "deletePersonError": "No se puede eliminar esta persona porque tiene relaciones registradas.", + "deleteAssignment": "Eliminar asignación", + "deletingAssignment": "Eliminando…", + "deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?", + "deleteAssignmentError": "No se puede eliminar la asignación." }, "access": { "helpTitle": "¿Qué es y para qué sirve?", diff --git a/modules/companies/src/web/assignments/shared/api/index.ts b/modules/companies/src/web/assignments/shared/api/index.ts index 9bddff1..e8df8ed 100644 --- a/modules/companies/src/web/assignments/shared/api/index.ts +++ b/modules/companies/src/web/assignments/shared/api/index.ts @@ -1 +1 @@ -export { saveAssignment } from "./save-assignment.api"; +export { deleteAssignment, saveAssignment } from "./save-assignment.api"; diff --git a/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts b/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts index 5151716..47a0e54 100644 --- a/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts +++ b/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts @@ -1,6 +1,14 @@ import { CompanyResponseSchema } from "../../../../common"; import { companyRequest } from "../../../companies/shared/api/company-request"; +export const deleteAssignment = (companyId: string, personId: string, id: string, token: string) => + companyRequest( + `/${companyId}/people/${personId}/assignments/${id}/delete`, + CompanyResponseSchema, + {}, + token + ); + export const saveAssignment = ( companyId: string, personId: string, diff --git a/modules/companies/src/web/people/detail/ui/person-details.tsx b/modules/companies/src/web/people/detail/ui/person-details.tsx index b220175..48e12ed 100644 --- a/modules/companies/src/web/people/detail/ui/person-details.tsx +++ b/modules/companies/src/web/people/detail/ui/person-details.tsx @@ -11,6 +11,8 @@ import { useTranslation } from "react-i18next"; import type { CompanyDTO, PersonDTO } from "../../../../common"; import { AssignmentEditorDialog, AssignmentHistoryDialog } from "../../../assignments"; +import { deleteAssignment } from "../../../assignments/shared/api"; +import { deletePerson } from "../../shared/api"; export function PersonDetails({ company, @@ -28,6 +30,8 @@ export function PersonDetails({ const { t, i18n } = useTranslation("companies"); const [editor, setEditor] = useState(); const [historyId, setHistoryId] = useState(); + const [deleting, setDeleting] = useState(false); + const [deletingAssignment, setDeletingAssignment] = useState(); const assignments = company.assignments.filter((item) => item.personId === person.id); const selected = assignments.find((item) => item.id === editor); const history = assignments.find((item) => item.id === historyId); @@ -46,9 +50,31 @@ export function PersonDetails({ {person.isContact ? ` · ${t("isContact")}` : ""}

- +
+ + +
@@ -132,6 +158,32 @@ export function PersonDetails({ setHistoryId(item.id)}> {t("history")} + { + if ( + !window.confirm( + t("deleteAssignmentConfirm", { + name: departmentName(item.departmentId), + }) + ) + ) { + return; + } + setDeletingAssignment(item.id); + try { + await deleteAssignment(company.id, person.id, item.id, csrfToken); + window.location.reload(); + } catch { + window.alert(t("deleteAssignmentError")); + } finally { + setDeletingAssignment(undefined); + } + }} + > + {t( + deletingAssignment === item.id ? "deletingAssignment" : "deleteAssignment" + )} + ), }, diff --git a/modules/companies/src/web/people/shared/api/index.ts b/modules/companies/src/web/people/shared/api/index.ts index e0dfd2d..0857bab 100644 --- a/modules/companies/src/web/people/shared/api/index.ts +++ b/modules/companies/src/web/people/shared/api/index.ts @@ -1 +1 @@ -export { savePerson } from "./save-person.api"; +export { deletePerson, savePerson } from "./save-person.api"; diff --git a/modules/companies/src/web/people/shared/api/save-person.api.ts b/modules/companies/src/web/people/shared/api/save-person.api.ts index 637c728..d22fd6f 100644 --- a/modules/companies/src/web/people/shared/api/save-person.api.ts +++ b/modules/companies/src/web/people/shared/api/save-person.api.ts @@ -13,3 +13,6 @@ export const savePerson = ( body, token ); + +export const deletePerson = (companyId: string, id: string, token: string) => + companyRequest(`/${companyId}/people/${id}/delete`, CompanyResponseSchema, {}, token);