diff --git a/modules/companies/src/api/application/folder-mapping-input.mapper.ts b/modules/companies/src/api/application/folder-mapping-input.mapper.ts index 7e78738..74082d9 100644 --- a/modules/companies/src/api/application/folder-mapping-input.mapper.ts +++ b/modules/companies/src/api/application/folder-mapping-input.mapper.ts @@ -1,28 +1,70 @@ -import {Result} from "@repo/rdx-utils"; -import type {FolderMapping} from "../domain/folder-mapping.aggregate"; -import {FolderMappingError,type FolderMappingInput} from "../domain/folder-mapping-values"; -import {FolderMappingValues} from "../domain/value-objects/folder-mapping-values"; -import type {FolderContext} from "./folder-mapping.repository"; -import {folderAvailable,folderReferences} from "./folder-mapping-policy"; -export function mapFolderInput(input:FolderMappingInput,context:FolderContext,previous?:FolderMapping){ - const mapped=FolderMappingValues.create(input);if(!mapped.isSuccess){return mapped;} - const values=mapped.data; - if(!folderReferences(values,context)){return Result.fail(new FolderMappingError("FOLDER_SCOPE_MISMATCH"));} - if(previous && !previous.values.sameTarget(values)){return Result.fail(new FolderMappingError("FOLDER_TARGET_IMMUTABLE"));} - const changed= !previous || previous.values.state!==values.state || !previous.values.path.equals(values.path) || - previous.values.toPrimitive().driveLetter!==values.toPrimitive().driveLetter; - if(["pending","configured"].includes(values.state) && changed && !folderAvailable(values,context)){ +import { Result } from "@repo/rdx-utils"; + +import type { FolderMapping } from "../domain/folder-mapping.aggregate"; +import { FolderMappingError, type FolderMappingInput } from "../domain/folder-mapping-values"; +import { FolderMappingValues } from "../domain/value-objects/folder-mapping-values"; + +import type { FolderContext } from "./folder-mapping.repository"; +import { folderAvailable, folderReferences } from "./folder-mapping-policy"; +export function mapFolderInput( + input: FolderMappingInput, + context: FolderContext, + previous?: FolderMapping +) { + const mapped = FolderMappingValues.create(input); + if (!mapped.isSuccess) { + return mapped; + } + const values = mapped.data; + if (!folderReferences(values, context)) { + return Result.fail(new FolderMappingError("FOLDER_SCOPE_MISMATCH")); + } + if (previous && !previous.values.sameTarget(values)) { + return Result.fail(new FolderMappingError("FOLDER_TARGET_IMMUTABLE")); + } + const changed = + !previous || + previous.values.state !== values.state || + !previous.values.path.equals(values.path) || + previous.values.toPrimitive().driveLetter !== values.toPrimitive().driveLetter; + if ( + ["pending", "configured"].includes(values.state) && + changed && + !folderAvailable(values, context) + ) { return Result.fail(new FolderMappingError("FOLDER_UNAVAILABLE")); } - const references=folderReferences(values,context); - if(!values.closed && context.mappings.some(item=>{ - if(item.id.equals(previous?.id) || item.values.closed || !item.values.profileId.equals(values.profileId)){return false;} - const other=folderReferences(item.values,context); - return item.values.needId.equals(values.needId) || other?.need.resourceId===references?.need.resourceId; - })){return Result.fail(new FolderMappingError("FOLDER_MAPPING_EXISTS"));} - const letter=values.driveLetter.getOrUndefined(); - if(!values.closed && letter && context.mappings.some(item=>!item.id.equals(previous?.id) && !item.values.closed && - item.values.profileId.equals(values.profileId) && item.values.driveLetter.getOrUndefined()?.equals(letter))){ + const references = folderReferences(values, context); + if ( + !values.closed && + context.mappings.some((item) => { + if ( + item.id.equals(previous?.id) || + item.values.closed || + !item.values.profileId.equals(values.profileId) + ) { + return false; + } + const other = folderReferences(item.values, context); + return ( + item.values.needId.equals(values.needId) || + other?.need.resourceId === references?.need.resourceId + ); + }) + ) { + return Result.fail(new FolderMappingError("FOLDER_MAPPING_EXISTS")); + } + const letter = values.driveLetter.getOrUndefined(); + if ( + !values.closed && + letter && + context.mappings.some( + (item) => + !(item.id.equals(previous?.id) || item.values.closed) && + item.values.profileId.equals(values.profileId) && + item.values.driveLetter.getOrUndefined()?.equals(letter) + ) + ) { return Result.fail(new FolderMappingError("DRIVE_LETTER_IN_USE")); } return mapped; diff --git a/modules/companies/src/api/application/folder-mapping-policy.ts b/modules/companies/src/api/application/folder-mapping-policy.ts index 0b5613c..95282ef 100644 --- a/modules/companies/src/api/application/folder-mapping-policy.ts +++ b/modules/companies/src/api/application/folder-mapping-policy.ts @@ -1,33 +1,67 @@ -import {projectAccess} from "../domain/access/access-projection"; -import type {FolderMappingValues} from "../domain/value-objects/folder-mapping-values"; -import {SharedFolderPath} from "../domain/value-objects/shared-folder-path"; -import type {FolderContext} from "./folder-mapping.repository"; -export function folderReferences(values:FolderMappingValues,context:FolderContext){ - const profile=context.profiles.find(item=>item.id.equals(values.profileId)); - const need=context.items.find(item=>item.id.equals(values.needId))?.values.data; - if(!profile || need?.kind!=="need" || profile.values.personId.toPrimitive()!==need.personId){return undefined;} - const resource=context.items.find(item=>item.id.toPrimitive()===need.resourceId)?.values.data; - if(resource?.kind!=="resource"){return undefined;}return {profile,need,resource}; +import { projectAccess } from "../domain/access/access-projection"; +import type { FolderMappingValues } from "../domain/value-objects/folder-mapping-values"; +import { SharedFolderPath } from "../domain/value-objects/shared-folder-path"; + +import type { FolderContext } from "./folder-mapping.repository"; +export function folderReferences(values: FolderMappingValues, context: FolderContext) { + const profile = context.profiles.find((item) => item.id.equals(values.profileId)); + const need = context.items.find((item) => item.id.equals(values.needId))?.values.data; + if ( + !profile || + need?.kind !== "need" || + profile.values.personId.toPrimitive() !== need.personId + ) { + return undefined; + } + const resource = context.items.find((item) => item.id.toPrimitive() === need.resourceId)?.values + .data; + if (resource?.kind !== "resource") { + return undefined; + } + return { profile, need, resource }; } -function activeReference(context:FolderContext,id:string|null){return id===null || context.items.some(item=>{ - const data=item.values.data;return item.id.toPrimitive()===id && "active" in data && data.active; -});} -export function folderAvailable(values:FolderMappingValues,context:FolderContext){ - const references=folderReferences(values,context); - if(!references){return false;} - const {profile,need,resource}=references; - const current=resource.reference===null ? undefined:SharedFolderPath.create(resource.reference); - return profile.values.active && resource.active && need.startDate<=context.today && (need.endDate===null || context.todayitem.id.equals(profile.values.personId) && item.active) && - context.assets.some(item=>item.id.equals(profile.values.assetId) && item.available) && - current?.isSuccess===true && values.path.equals(current.data) && - activeReference(context,resource.resourceTypeId) && activeReference(context,resource.systemId) && activeReference(context,resource.environmentId) && - need.permissionIds.every(id=>activeReference(context,id)); +function activeReference(context: FolderContext, id: string | null) { + return ( + id === null || + context.items.some((item) => { + const data = item.values.data; + return item.id.toPrimitive() === id && "active" in data && data.active; + }) + ); +} +export function folderAvailable(values: FolderMappingValues, context: FolderContext) { + const references = folderReferences(values, context); + if (!references) { + return false; + } + const { profile, need, resource } = references; + const current = + resource.reference === null ? undefined : SharedFolderPath.create(resource.reference); + return ( + profile.values.active && + resource.active && + need.startDate <= context.today && + (need.endDate === null || context.today < need.endDate) && + context.people.some((item) => item.id.equals(profile.values.personId) && item.active) && + context.assets.some((item) => item.id.equals(profile.values.assetId) && item.available) && + current?.isSuccess === true && + values.path.equals(current.data) && + activeReference(context, resource.resourceTypeId) && + activeReference(context, resource.systemId) && + activeReference(context, resource.environmentId) && + need.permissionIds.every((id) => activeReference(context, id)) + ); } -export function folderCoverage(context:FolderContext){ - const current=context.items.filter(item=>{ - const values=item.values.data;return !("startDate" in values) || values.startDate<=context.today; +export function folderCoverage(context: FolderContext) { + const current = context.items.filter((item) => { + const values = item.values.data; + return !("startDate" in values) || values.startDate <= context.today; }); - return new Map(projectAccess(current,context.people,context.today).coverage.map(item=>[item.needId,item.covered])); + return new Map( + projectAccess(current, context.people, context.today).coverage.map((item) => [ + item.needId, + item.covered, + ]) + ); } diff --git a/modules/companies/src/api/application/folder-mapping-response.builder.ts b/modules/companies/src/api/application/folder-mapping-response.builder.ts index b5f4c84..9b3157c 100644 --- a/modules/companies/src/api/application/folder-mapping-response.builder.ts +++ b/modules/companies/src/api/application/folder-mapping-response.builder.ts @@ -1,12 +1,29 @@ -import type {FolderMappingDTO} from "../../common/folder-mappings"; +import type { FolderMappingDTO } from "../../common/folder-mappings"; +import type { FolderMapping } from "../domain/folder-mapping.aggregate"; -import type {FolderMapping} from "../domain/folder-mapping.aggregate"; -import type {FolderContext} from "./folder-mapping.repository"; -import {folderAvailable,folderCoverage} from "./folder-mapping-policy"; -export function buildFolderResponse(item:FolderMapping,context:FolderContext,coverage=folderCoverage(context)):FolderMappingDTO{ - const permissionsCovered=folderAvailable(item.values,context) && coverage.get(item.values.needId.toPrimitive())===true; - return {id:item.id.toPrimitive(),companyId:item.companyId.toPrimitive(),values:item.values.toPrimitive(),permissionsCovered, - needsReview:!item.values.closed && !folderAvailable(item.values,context), - history:item.history.map(revision=>({id:revision.id.toPrimitive(),actorId:revision.actorId.toPrimitive(), - actorName:context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),recordedAt:revision.recordedAt,values:revision.values.toPrimitive()}))}; +import type { FolderContext } from "./folder-mapping.repository"; +import { folderAvailable, folderCoverage } from "./folder-mapping-policy"; +export function buildFolderResponse( + item: FolderMapping, + context: FolderContext, + coverage = folderCoverage(context) +): FolderMappingDTO { + const permissionsCovered = + folderAvailable(item.values, context) && + coverage.get(item.values.needId.toPrimitive()) === true; + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + permissionsCovered, + needsReview: !(item.values.closed || folderAvailable(item.values, context)), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: + context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: revision.values.toPrimitive(), + })), + }; } diff --git a/modules/companies/src/api/application/folder-mapping.repository.ts b/modules/companies/src/api/application/folder-mapping.repository.ts index 4d95bf2..f672cf1 100644 --- a/modules/companies/src/api/application/folder-mapping.repository.ts +++ b/modules/companies/src/api/application/folder-mapping.repository.ts @@ -1,17 +1,23 @@ -import type {UniqueID} from "@repo/rdx-ddd"; -import type {Result} from "@repo/rdx-utils"; -import type {AccessValues} from "../domain/access/access-values"; -import type {FolderMapping} from "../domain/folder-mapping.aggregate"; -import type {FolderMappingError} from "../domain/folder-mapping-values"; -import type {WorkProfile} from "../domain/work-profile.aggregate"; -export type FolderContext=Readonly<{ - mappings:readonly FolderMapping[];profiles:readonly WorkProfile[]; - items:readonly {id:UniqueID;values:AccessValues}[]; - people:readonly {id:UniqueID;active:boolean}[];assets:readonly {id:UniqueID;available:boolean}[]; - actors:ReadonlyMap;today:string; +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { AccessValues } from "../domain/access/access-values"; +import type { FolderMapping } from "../domain/folder-mapping.aggregate"; +import type { FolderMappingError } from "../domain/folder-mapping-values"; +import type { WorkProfile } from "../domain/work-profile.aggregate"; +export type FolderContext = Readonly<{ + mappings: readonly FolderMapping[]; + profiles: readonly WorkProfile[]; + items: readonly { id: UniqueID; values: AccessValues }[]; + people: readonly { id: UniqueID; active: boolean }[]; + assets: readonly { id: UniqueID; available: boolean }[]; + actors: ReadonlyMap; + today: string; }>; -export interface IFolderMappingRepository{ - get(company:UniqueID):Promise>; - save(company:UniqueID,action:(context:FolderContext)=>Result): - Promise>; +export interface IFolderMappingRepository { + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: (context: FolderContext) => Result + ): Promise>; } diff --git a/modules/companies/src/api/application/folder-mapping.use-cases.ts b/modules/companies/src/api/application/folder-mapping.use-cases.ts index 1d8a05b..64b34c2 100644 --- a/modules/companies/src/api/application/folder-mapping.use-cases.ts +++ b/modules/companies/src/api/application/folder-mapping.use-cases.ts @@ -2,10 +2,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { FolderMapping } from "../domain/folder-mapping.aggregate"; -import { - FolderMappingError, - type FolderMappingInput, -} from "../domain/folder-mapping-values"; +import { FolderMappingError, type FolderMappingInput } from "../domain/folder-mapping-values"; import type { IFolderMappingRepository } from "./folder-mapping.repository"; import { mapFolderInput } from "./folder-mapping-input.mapper"; diff --git a/modules/companies/src/api/domain/access/access-projection.ts b/modules/companies/src/api/domain/access/access-projection.ts index d5c0417..2fbf38e 100644 --- a/modules/companies/src/api/domain/access/access-projection.ts +++ b/modules/companies/src/api/domain/access/access-projection.ts @@ -15,10 +15,10 @@ type Reason = | "inactive_catalog"; type Coverage = { needId: string; covered: boolean; missingPermissionIds: string[] }; class AccessProjection { - private readonly entries: ReadonlyMap>; + private readonly entries: ReadonlyMap>; constructor( - private readonly items: readonly Pick[], - private readonly people: readonly Pick[], + private readonly items: readonly Pick[], + private readonly people: readonly Pick[], private readonly today: string ) { this.entries = new Map(items.map((item) => [item.id.toPrimitive(), item])); @@ -60,7 +60,7 @@ class AccessProjection { const need = this.data(needId); return need?.kind === "need" && need.permissionIds.includes(permissionId); } - authorizedLink = (item: Pick) => { + authorizedLink = (item: Pick) => { const value = item.values.data; if (value.kind !== "coverage" || value.endDate !== null || !this.isOpen(value.needId)) { return false; @@ -81,7 +81,7 @@ class AccessProjection { this.membershipAuthorized(value.membershipId) ); }; - validLink = (item: Pick) => { + validLink = (item: Pick) => { const value = item.values.data; if (value.kind !== "coverage" || !this.authorizedLink(item)) { return false; @@ -92,7 +92,7 @@ class AccessProjection { (value.membershipId === null || this.configured(value.membershipId)) ); }; - needCoverage(item: Pick): Coverage[] { + needCoverage(item: Pick): Coverage[] { const value = item.values.data; if (value.kind !== "need") { return []; @@ -122,7 +122,7 @@ class AccessProjection { ]; } private accountReview( - item: Pick, + item: Pick, value: Extract ): Reason[] { const reasons: Reason[] = []; @@ -158,7 +158,7 @@ class AccessProjection { return reasons; } private grantReview( - item: Pick, + item: Pick, value: Extract ): Reason[] { if (value.endDate !== null) { @@ -187,7 +187,7 @@ class AccessProjection { return reasons; } private needReview( - item: Pick, + item: Pick, value: Extract, coverage: readonly Coverage[] ): Reason[] { @@ -203,7 +203,10 @@ class AccessProjection { } return reasons; } - private specificReview(item: Pick, coverage: readonly Coverage[]): Reason[] { + private specificReview( + item: Pick, + coverage: readonly Coverage[] + ): Reason[] { const value = item.values.data; switch (value.kind) { case "account": @@ -254,8 +257,8 @@ class AccessProjection { } } export function projectAccess( - items: readonly Pick[], - people: readonly Pick[], + items: readonly Pick[], + people: readonly Pick[], today: string ) { return new AccessProjection(items, people, today).project(); diff --git a/modules/companies/src/api/domain/folder-mapping-values.ts b/modules/companies/src/api/domain/folder-mapping-values.ts index cfd6f0a..fc98f67 100644 --- a/modules/companies/src/api/domain/folder-mapping-values.ts +++ b/modules/companies/src/api/domain/folder-mapping-values.ts @@ -1,15 +1,28 @@ -import {z} from "zod/v4"; -import {ConfigurationSchema} from "./access/access-definition"; -export const FolderMappingInputSchema=z.strictObject({ - profileId:z.uuid(),needId:z.uuid(),path:z.string().trim().min(1).max(255), - driveLetter:z.string().trim().regex(/^[A-Za-z]:?$/).nullable(), - state:ConfigurationSchema,notes:z.string().trim().max(4000).nullable(), +import { z } from "zod/v4"; + +import { ConfigurationSchema } from "./access/access-definition"; +export const FolderMappingInputSchema = z.strictObject({ + profileId: z.uuid(), + needId: z.uuid(), + path: z.string().trim().min(1).max(255), + driveLetter: z + .string() + .trim() + .regex(/^[A-Za-z]:?$/) + .nullable(), + state: ConfigurationSchema, + notes: z.string().trim().max(4000).nullable(), }); -export type FolderMappingInput=z.infer; -export class FolderMappingError extends Error {constructor(public readonly code:string){super(code);}} -export const FOLDER_STATE_TRANSITIONS={ - pending:["pending","configured","never_configured"], - configured:["configured","removal_pending"], - removal_pending:["removal_pending","removed"], - removed:["removed"],never_configured:["never_configured"], +export type FolderMappingInput = z.infer; +export class FolderMappingError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export const FOLDER_STATE_TRANSITIONS = { + pending: ["pending", "configured", "never_configured"], + configured: ["configured", "removal_pending"], + removal_pending: ["removal_pending", "removed"], + removed: ["removed"], + never_configured: ["never_configured"], } as const; diff --git a/modules/companies/src/api/domain/folder-mapping.aggregate.ts b/modules/companies/src/api/domain/folder-mapping.aggregate.ts index aa65d91..bc077ca 100644 --- a/modules/companies/src/api/domain/folder-mapping.aggregate.ts +++ b/modules/companies/src/api/domain/folder-mapping.aggregate.ts @@ -77,10 +77,7 @@ export class FolderMapping extends AggregateRoot { { ...this.props, values, - history: [ - ...this.history, - FolderMappingRevision.create({ values, actorId, recordedAt }), - ], + history: [...this.history, FolderMappingRevision.create({ values, actorId, recordedAt })], }, this.id ) diff --git a/modules/companies/src/api/domain/value-objects/drive-letter.ts b/modules/companies/src/api/domain/value-objects/drive-letter.ts index 169a0f2..04ddcaf 100644 --- a/modules/companies/src/api/domain/value-objects/drive-letter.ts +++ b/modules/companies/src/api/domain/value-objects/drive-letter.ts @@ -1,11 +1,20 @@ -import {Result} from "@repo/rdx-utils"; -import {FolderMappingError} from "../folder-mapping-values"; -export class DriveLetter{ - private constructor(private readonly value:string){Object.freeze(this);} - static create(input:string):Result{ - const value=input.trim().toUpperCase().replace(/:$/,""); - return /^[A-Z]$/.test(value) ? Result.ok(new DriveLetter(value)):Result.fail(new FolderMappingError("INVALID_DRIVE_LETTER")); +import { Result } from "@repo/rdx-utils"; + +import { FolderMappingError } from "../folder-mapping-values"; +export class DriveLetter { + private constructor(private readonly value: string) { + Object.freeze(this); + } + static create(input: string): Result { + const value = input.trim().toUpperCase().replace(/:$/, ""); + return /^[A-Z]$/.test(value) + ? Result.ok(new DriveLetter(value)) + : Result.fail(new FolderMappingError("INVALID_DRIVE_LETTER")); + } + toPrimitive() { + return this.value; + } + equals(other: DriveLetter) { + return this.value === other.value; } - toPrimitive(){return this.value;} - equals(other:DriveLetter){return this.value===other.value;} } diff --git a/modules/companies/src/api/domain/value-objects/folder-mapping-values.ts b/modules/companies/src/api/domain/value-objects/folder-mapping-values.ts index 736cde6..3092a11 100644 --- a/modules/companies/src/api/domain/value-objects/folder-mapping-values.ts +++ b/modules/companies/src/api/domain/value-objects/folder-mapping-values.ts @@ -1,25 +1,78 @@ -import {UniqueID} from "@repo/rdx-ddd"; -import {Maybe,Result} from "@repo/rdx-utils"; -import {FolderMappingError,FolderMappingInputSchema,type FolderMappingInput} from "../folder-mapping-values"; -import {DriveLetter} from "./drive-letter"; -import {SharedFolderPath} from "./shared-folder-path"; -export class FolderMappingValues{ - private constructor(public readonly profileId:UniqueID,public readonly needId:UniqueID,public readonly path:SharedFolderPath, - public readonly driveLetter:Maybe,public readonly state:FolderMappingInput["state"],public readonly notes:Maybe){Object.freeze(this);} - static create(input:FolderMappingInput):Result{ - const parsed=FolderMappingInputSchema.safeParse(input); - if(!parsed.success){return Result.fail(new FolderMappingError("INVALID_REQUEST"));} - const data=parsed.data,profile=UniqueID.create(data.profileId),need=UniqueID.create(data.needId),path=SharedFolderPath.create(data.path); - const letter=data.driveLetter===null ? undefined:DriveLetter.create(data.driveLetter); - if(!(profile.isSuccess && need.isSuccess)){return Result.fail(new FolderMappingError("INVALID_REQUEST"));} - if(!path.isSuccess){return path;} if(letter && !letter.isSuccess){return letter;} - return Result.ok(new FolderMappingValues(profile.data,need.data,path.data,letter?.isSuccess ? Maybe.some(letter.data):Maybe.none(), - data.state,data.notes ? Maybe.some(data.notes):Maybe.none())); +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + FolderMappingError, + type FolderMappingInput, + FolderMappingInputSchema, +} from "../folder-mapping-values"; + +import { DriveLetter } from "./drive-letter"; +import { SharedFolderPath } from "./shared-folder-path"; +export class FolderMappingValues { + private constructor( + public readonly profileId: UniqueID, + public readonly needId: UniqueID, + public readonly path: SharedFolderPath, + public readonly driveLetter: Maybe, + public readonly state: FolderMappingInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create(input: FolderMappingInput): Result { + const parsed = FolderMappingInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new FolderMappingError("INVALID_REQUEST")); + } + const data = parsed.data; + const profile = UniqueID.create(data.profileId); + const need = UniqueID.create(data.needId); + const path = SharedFolderPath.create(data.path); + const letter = data.driveLetter === null ? undefined : DriveLetter.create(data.driveLetter); + if (!(profile.isSuccess && need.isSuccess)) { + return Result.fail(new FolderMappingError("INVALID_REQUEST")); + } + if (!path.isSuccess) { + return path; + } + if (letter && !letter.isSuccess) { + return letter; + } + return Result.ok( + new FolderMappingValues( + profile.data, + need.data, + path.data, + letter?.isSuccess ? Maybe.some(letter.data) : Maybe.none(), + data.state, + data.notes ? Maybe.some(data.notes) : Maybe.none() + ) + ); + } + get closed() { + return this.state === "removed" || this.state === "never_configured"; + } + sameTarget(other: FolderMappingValues) { + return this.profileId.equals(other.profileId) && this.needId.equals(other.needId); + } + equals(other: FolderMappingValues) { + return ( + this.sameTarget(other) && + this.path.equals(other.path) && + this.toPrimitive().driveLetter === other.toPrimitive().driveLetter && + this.state === other.state && + this.toPrimitive().notes === other.toPrimitive().notes + ); + } + toPrimitive(): FolderMappingInput { + return { + profileId: this.profileId.toPrimitive(), + needId: this.needId.toPrimitive(), + path: this.path.toPrimitive(), + driveLetter: this.driveLetter.getOrUndefined()?.toPrimitive() ?? null, + state: this.state, + notes: this.notes.getOrUndefined() ?? null, + }; } - get closed(){return this.state==="removed" || this.state==="never_configured";} - sameTarget(other:FolderMappingValues){return this.profileId.equals(other.profileId) && this.needId.equals(other.needId);} - equals(other:FolderMappingValues){return this.sameTarget(other) && this.path.equals(other.path) && - this.toPrimitive().driveLetter===other.toPrimitive().driveLetter && this.state===other.state && this.toPrimitive().notes===other.toPrimitive().notes;} - toPrimitive():FolderMappingInput{return {profileId:this.profileId.toPrimitive(),needId:this.needId.toPrimitive(),path:this.path.toPrimitive(), - driveLetter:this.driveLetter.getOrUndefined()?.toPrimitive() ?? null,state:this.state,notes:this.notes.getOrUndefined() ?? null};} } diff --git a/modules/companies/src/api/domain/value-objects/shared-folder-path.ts b/modules/companies/src/api/domain/value-objects/shared-folder-path.ts index 9b70326..9490bfa 100644 --- a/modules/companies/src/api/domain/value-objects/shared-folder-path.ts +++ b/modules/companies/src/api/domain/value-objects/shared-folder-path.ts @@ -1,17 +1,32 @@ -import {Result} from "@repo/rdx-utils"; -import {FolderMappingError} from "../folder-mapping-values"; +import { Result } from "@repo/rdx-utils"; + +import { FolderMappingError } from "../folder-mapping-values"; /** Ruta UNC; distingue un recurso compartido de una carpeta local o una URL. */ -export class SharedFolderPath{ - private constructor(private readonly value:string){Object.freeze(this);} - static create(input:string):Result{ - const value=input.trim().replace(/\\+$/,""); - if(value.length>255 || !/^\\\\[^\\/\s:*?"<>|]+\\[^\\/:*?"<>|]+(?:\\[^\\/:*?"<>|]+)*$/.test(value) || - value.split("\\").slice(2).some(part=>part==="." || part===".." || !part.trim())){ +export class SharedFolderPath { + private constructor(private readonly value: string) { + Object.freeze(this); + } + static create(input: string): Result { + const value = input.trim().replace(/\\+$/, ""); + if ( + value.length > 255 || + !/^\\\\[^\\/\s:*?"<>|]+\\[^\\/:*?"<>|]+(?:\\[^\\/:*?"<>|]+)*$/.test(value) || + value + .split("\\") + .slice(2) + .some((part) => part === "." || part === ".." || !part.trim()) + ) { return Result.fail(new FolderMappingError("INVALID_FOLDER_PATH")); } return Result.ok(new SharedFolderPath(value)); } - get server(){return this.value.split("\\")[2] ?? "";} - toPrimitive(){return this.value;} - equals(other:SharedFolderPath){return this.value.toLowerCase()===other.value.toLowerCase();} + get server() { + return this.value.split("\\")[2] ?? ""; + } + toPrimitive() { + return this.value; + } + equals(other: SharedFolderPath) { + return this.value.toLowerCase() === other.value.toLowerCase(); + } } diff --git a/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts b/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts index e428875..65c1a34 100644 --- a/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts +++ b/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts @@ -1,11 +1,8 @@ import { type Application, type RequestHandler, type Response, Router } from "express"; import { z } from "zod/v4"; -import { - MailItemParamsSchema, - MailParamsSchema, - } from "../../../common/mail-configurations"; -import {SaveFolderMappingRequestSchema} from "../../../common/folder-mappings"; +import { SaveFolderMappingRequestSchema } from "../../../common/folder-mappings"; +import { MailItemParamsSchema, MailParamsSchema } from "../../../common/mail-configurations"; import type { FolderMappingUseCases } from "../../application/folder-mapping.use-cases"; import { buildFolderResponse } from "../../application/folder-mapping-response.builder"; @@ -41,9 +38,9 @@ export function registerFolderMappingRoutes( }); for (const path of ["/:companyId", "/:companyId/:id/update"]) { router.post(path, (request, response, next) => { - const params = ( - path.endsWith("update") ? MailItemParamsSchema : MailParamsSchema - ).safeParse(request.params); + const params = (path.endsWith("update") ? MailItemParamsSchema : MailParamsSchema).safeParse( + request.params + ); const body = SaveFolderMappingRequestSchema.safeParse(request.body); const actor = z .object({ userId: z.uuid() }) diff --git a/modules/companies/src/api/infrastructure/persistence/folder-mapping-migration.ts b/modules/companies/src/api/infrastructure/persistence/folder-mapping-migration.ts index 7014938..dd9e038 100644 --- a/modules/companies/src/api/infrastructure/persistence/folder-mapping-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/folder-mapping-migration.ts @@ -1,17 +1,45 @@ -import {DataTypes,QueryTypes,type Sequelize} from "sequelize"; -export async function migrateFolderMappings(database:Sequelize){ - const q=database.getQueryInterface(); - const tables=await database.query<{name:string}>("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",{type:QueryTypes.SELECT}); - const fk=(table:string)=>({type:DataTypes.UUID,allowNull:false,references:{model:table,key:"id"},onDelete:"RESTRICT"}); - if(!tables.some(t=>t.name==="folder_mappings")){ - await q.createTable("folder_mappings",{id:{type:DataTypes.UUID,primaryKey:true},companyId:fk("companies"),profileId:fk("work_profiles"),needId:fk("access_items"), - values:{type:DataTypes.JSON,allowNull:false},currentKey:{type:DataTypes.STRING(110),allowNull:true,unique:true}, - driveKey:{type:DataTypes.STRING(40),allowNull:true,unique:true}}); +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateFolderMappings(database: Sequelize) { + const q = database.getQueryInterface(); + const tables = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const fk = (table: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + if (!tables.some((t) => t.name === "folder_mappings")) { + await q.createTable("folder_mappings", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + profileId: fk("work_profiles"), + needId: fk("access_items"), + values: { type: DataTypes.JSON, allowNull: false }, + currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true }, + driveKey: { type: DataTypes.STRING(40), allowNull: true, unique: true }, + }); } - if(!tables.some(t=>t.name==="folder_mapping_revisions")){ - await q.createTable("folder_mapping_revisions",{id:{type:DataTypes.UUID,primaryKey:true},mappingId:fk("folder_mappings"),position:{type:DataTypes.INTEGER,allowNull:false}, - values:{type:DataTypes.JSON,allowNull:false},actorId:fk("users"),recordedAt:{type:DataTypes.STRING(24),allowNull:false}}); + if (!tables.some((t) => t.name === "folder_mapping_revisions")) { + await q.createTable("folder_mapping_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + mappingId: fk("folder_mappings"), + position: { type: DataTypes.INTEGER, allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + actorId: fk("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + }); + } + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='folder_mapping_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "folder_mapping_position")) { + await q.addIndex("folder_mapping_revisions", ["mappingId", "position"], { + unique: true, + name: "folder_mapping_position", + }); } - const indexes=await database.query<{name:string}>("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='folder_mapping_revisions'",{type:QueryTypes.SELECT}); - if(!indexes.some(item=>item.name==="folder_mapping_position")){await q.addIndex("folder_mapping_revisions",["mappingId","position"],{unique:true,name:"folder_mapping_position"});} } diff --git a/modules/companies/src/api/infrastructure/persistence/folder-mapping-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/folder-mapping-persistence.mapper.ts index fc5e39b..be87adb 100644 --- a/modules/companies/src/api/infrastructure/persistence/folder-mapping-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/folder-mapping-persistence.mapper.ts @@ -1,46 +1,117 @@ -import {z} from "zod/v4"; -import {FolderMapping} from "../../domain/folder-mapping.aggregate"; -import {FolderMappingRevision} from "../../domain/folder-mapping-revision.entity"; -import {FolderMappingInputSchema,FOLDER_STATE_TRANSITIONS} from "../../domain/folder-mapping-values"; -import {FolderMappingValues} from "../../domain/value-objects/folder-mapping-values"; -import {profileIdentity,persistedJson} from "./work-profile-persistence.mapper"; -export type FolderRow={id:string;companyId:string;profileId:string;needId:string;values:unknown;currentKey:string|null;driveKey:string|null}; -export type FolderRevisionRow={id:string;mappingId:string;position:number;values:unknown;actorId:string;recordedAt:string}; -function mapped(raw:unknown){ - const r=FolderMappingValues.create(FolderMappingInputSchema.parse(persistedJson(raw))); - if(!r.isSuccess){throw new Error("Valores de carpeta persistidos inconsistentes.");}return r.data; +import { z } from "zod/v4"; + +import { FolderMapping } from "../../domain/folder-mapping.aggregate"; +import { FolderMappingRevision } from "../../domain/folder-mapping-revision.entity"; +import { + FOLDER_STATE_TRANSITIONS, + FolderMappingInputSchema, +} from "../../domain/folder-mapping-values"; +import { FolderMappingValues } from "../../domain/value-objects/folder-mapping-values"; + +import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper"; +export type FolderRow = { + id: string; + companyId: string; + profileId: string; + needId: string; + values: unknown; + currentKey: string | null; + driveKey: string | null; +}; +export type FolderRevisionRow = { + id: string; + mappingId: string; + position: number; + values: unknown; + actorId: string; + recordedAt: string; +}; +function mapped(raw: unknown) { + const r = FolderMappingValues.create(FolderMappingInputSchema.parse(persistedJson(raw))); + if (!r.isSuccess) { + throw new Error("Valores de carpeta persistidos inconsistentes."); + } + return r.data; } -export class FolderMappingPersistenceMapper{ - toDomain(row:FolderRow,revisions:readonly FolderRevisionRow[]){ - const values=mapped(row.values),ordered=[...revisions].sort((a,b)=>a.position-b.position); - const history=ordered.map((revision,index)=>{ - const value=mapped(revision.values),previous=ordered[index-1]; - if(revision.position!==index || !z.iso.datetime().safeParse(revision.recordedAt).success || - revision.recordedAt<(previous?.recordedAt ?? "") || !values.sameTarget(value)){ +export class FolderMappingPersistenceMapper { + toDomain(row: FolderRow, revisions: readonly FolderRevisionRow[]) { + const values = mapped(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, index) => { + const value = mapped(revision.values); + const previous = ordered[index - 1]; + if ( + revision.position !== index || + !z.iso.datetime().safeParse(revision.recordedAt).success || + revision.recordedAt < (previous?.recordedAt ?? "") || + !values.sameTarget(value) + ) { throw new Error("Historial de carpeta persistido inconsistente."); } - return FolderMappingRevision.rehydrate({values:value,actorId:profileIdentity(revision.actorId),recordedAt:revision.recordedAt},profileIdentity(revision.id)); + return FolderMappingRevision.rehydrate( + { + values: value, + actorId: profileIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + profileIdentity(revision.id) + ); }); - for(const [index,revision] of history.entries()){ - const previous=history[index-1]?.values; - if(previous && (!FOLDER_STATE_TRANSITIONS[previous.state].some(state=>state===revision.values.state) || - (previous.closed && !previous.equals(revision.values)))){throw new Error("Estados de carpeta históricos inconsistentes.");} + for (const [index, revision] of history.entries()) { + const previous = history[index - 1]?.values; + if ( + previous && + (!FOLDER_STATE_TRANSITIONS[previous.state].some( + (state) => state === revision.values.state + ) || + (previous.closed && !previous.equals(revision.values))) + ) { + throw new Error("Estados de carpeta históricos inconsistentes."); + } } - const keys=folderKeys(values); - if(!history.at(-1)?.values.equals(values) || row.profileId!==values.profileId.toPrimitive() || row.needId!==values.needId.toPrimitive() || - row.currentKey!==keys.currentKey || row.driveKey!==keys.driveKey){throw new Error("Referencias de carpeta persistidas inconsistentes.");} - return FolderMapping.rehydrate({companyId:profileIdentity(row.companyId),values,history},profileIdentity(row.id)); + const keys = folderKeys(values); + if ( + !history.at(-1)?.values.equals(values) || + row.profileId !== values.profileId.toPrimitive() || + row.needId !== values.needId.toPrimitive() || + row.currentKey !== keys.currentKey || + row.driveKey !== keys.driveKey + ) { + throw new Error("Referencias de carpeta persistidas inconsistentes."); + } + return FolderMapping.rehydrate( + { companyId: profileIdentity(row.companyId), values, history }, + profileIdentity(row.id) + ); } - toPersistence(item:FolderMapping){ - const values=item.values.toPrimitive(); - return {root:{id:item.id.toPrimitive(),companyId:item.companyId.toPrimitive(),profileId:values.profileId,needId:values.needId, - values:JSON.stringify(values),...folderKeys(item.values)}, - revisions:item.history.map((revision,position)=>({id:revision.id.toPrimitive(),mappingId:item.id.toPrimitive(),position, - actorId:revision.actorId.toPrimitive(),recordedAt:revision.recordedAt,values:JSON.stringify(revision.values.toPrimitive())}))}; + toPersistence(item: FolderMapping) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + profileId: values.profileId, + needId: values.needId, + values: JSON.stringify(values), + ...folderKeys(item.values), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + mappingId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: JSON.stringify(revision.values.toPrimitive()), + })), + }; } } -function folderKeys(values:FolderMappingValues){ - const letter=values.driveLetter.getOrUndefined()?.toPrimitive(); - return {currentKey:values.closed ? null:values.profileId.toPrimitive()+":"+values.needId.toPrimitive(), - driveKey:values.closed || !letter ? null:values.profileId.toPrimitive()+":"+letter}; +function folderKeys(values: FolderMappingValues) { + const letter = values.driveLetter.getOrUndefined()?.toPrimitive(); + return { + currentKey: values.closed + ? null + : `${values.profileId.toPrimitive()}:${values.needId.toPrimitive()}`, + driveKey: values.closed || !letter ? null : `${values.profileId.toPrimitive()}:${letter}`, + }; } diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts index d34726b..4f53af4 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository.ts @@ -1,55 +1,150 @@ -import type {UniqueID} from "@repo/rdx-ddd"; -import {Result} from "@repo/rdx-utils"; -import {QueryTypes,type Sequelize,type Transaction} from "sequelize"; -import type {FolderContext,IFolderMappingRepository} from "../../application/folder-mapping.repository"; -import type {IWorkProfileInventoryService} from "../../application/work-profile.repository"; -import {folderReferences} from "../../application/folder-mapping-policy"; -import {AccessDefinitionSchema} from "../../domain/access/access-definition"; -import {AccessValues} from "../../domain/access/access-values"; -import {FolderMappingError} from "../../domain/folder-mapping-values"; -import {FolderMappingPersistenceMapper,type FolderRow,type FolderRevisionRow} from "./folder-mapping-persistence.mapper"; -import {WorkProfilePersistenceMapper,type ProfileRow,type ProfileRevisionRow,profileIdentity,persistedJson} from "./work-profile-persistence.mapper"; -export class SequelizeFolderMappingRepository implements IFolderMappingRepository{ - private readonly mapper=new FolderMappingPersistenceMapper(); - constructor(private readonly database:Sequelize,private readonly inventory:IWorkProfileInventoryService){} - private async load(company:UniqueID,transaction:Transaction,write:boolean):Promise>{ - const replacements={companyId:company.toPrimitive()},options={replacements,transaction,type:QueryTypes.SELECT}; - const companies=await this.database.query<{id:string}>(`SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE":""}`,{...options,type:QueryTypes.SELECT}); - if(!companies.length){return Result.fail(new FolderMappingError("COMPANY_NOT_FOUND"));} - const rows=await this.database.query("SELECT * FROM folder_mappings WHERE companyId=:companyId",{...options,type:QueryTypes.SELECT}); - const revisions=await this.database.query("SELECT r.* FROM folder_mapping_revisions r JOIN folder_mappings m ON m.id=r.mappingId WHERE m.companyId=:companyId",{...options,type:QueryTypes.SELECT}); - const profiles=await this.database.query("SELECT * FROM work_profiles WHERE companyId=:companyId",{...options,type:QueryTypes.SELECT}); - const profileRevisions=await this.database.query("SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId",{...options,type:QueryTypes.SELECT}); - const items=await this.database.query<{id:string;values:unknown}>("SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",{...options,type:QueryTypes.SELECT}); - const people=await this.database.query<{id:string;active:boolean}>("SELECT id,active FROM company_contacts WHERE companyId=:companyId",{...options,type:QueryTypes.SELECT}); - const actors=await this.database.query<{id:string;name:string}>("SELECT id,name FROM users",{transaction,type:QueryTypes.SELECT}); - const context:FolderContext={mappings:rows.map(row=>this.mapper.toDomain(row,revisions.filter(r=>r.mappingId===row.id))), - profiles:profiles.map(row=>new WorkProfilePersistenceMapper().toDomain(row,profileRevisions.filter(r=>r.profileId===row.id))), - items:items.map(item=>{const values=AccessValues.create(AccessDefinitionSchema.parse(persistedJson(item.values))); - if(!values.isSuccess){throw new Error("Recurso de carpeta persistido inconsistente.");} - return {id:profileIdentity(item.id),values:values.data};}), - people:people.map(item=>({id:profileIdentity(item.id),active:Boolean(item.active)})),assets:await this.inventory.list(company), - actors:new Map(actors.map(actor=>[actor.id,actor.name])),today:new Date().toISOString().slice(0,10)}; - if(context.profiles.some(profile=>!context.people.some(person=>person.id.equals(profile.values.personId)) || - !context.assets.some(asset=>asset.id.equals(profile.values.assetId))) || - context.mappings.some(mapping=>!folderReferences(mapping.values,context))){throw new Error("Ámbito de carpeta persistido inconsistente.");} +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + FolderContext, + IFolderMappingRepository, +} from "../../application/folder-mapping.repository"; +import { folderReferences } from "../../application/folder-mapping-policy"; +import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; +import { AccessDefinitionSchema } from "../../domain/access/access-definition"; +import { AccessValues } from "../../domain/access/access-values"; +import { FolderMappingError } from "../../domain/folder-mapping-values"; + +import { + FolderMappingPersistenceMapper, + type FolderRevisionRow, + type FolderRow, +} from "./folder-mapping-persistence.mapper"; +import { + type ProfileRevisionRow, + type ProfileRow, + WorkProfilePersistenceMapper, + persistedJson, + profileIdentity, +} from "./work-profile-persistence.mapper"; +export class SequelizeFolderMappingRepository implements IFolderMappingRepository { + private readonly mapper = new FolderMappingPersistenceMapper(); + constructor( + private readonly database: Sequelize, + private readonly inventory: IWorkProfileInventoryService + ) {} + private async load( + company: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const replacements = { companyId: company.toPrimitive() }; + const options = { replacements, transaction, type: QueryTypes.SELECT }; + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { ...options, type: QueryTypes.SELECT } + ); + if (!companies.length) { + return Result.fail(new FolderMappingError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM folder_mappings WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM folder_mapping_revisions r JOIN folder_mappings m ON m.id=r.mappingId WHERE m.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profiles = await this.database.query( + "SELECT * FROM work_profiles WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profileRevisions = await this.database.query( + "SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const items = await this.database.query<{ id: string; values: unknown }>( + "SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL", + { ...options, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + const context: FolderContext = { + mappings: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((r) => r.mappingId === row.id) + ) + ), + profiles: profiles.map((row) => + new WorkProfilePersistenceMapper().toDomain( + row, + profileRevisions.filter((r) => r.profileId === row.id) + ) + ), + items: items.map((item) => { + const values = AccessValues.create( + AccessDefinitionSchema.parse(persistedJson(item.values)) + ); + if (!values.isSuccess) { + throw new Error("Recurso de carpeta persistido inconsistente."); + } + return { id: profileIdentity(item.id), values: values.data }; + }), + people: people.map((item) => ({ + id: profileIdentity(item.id), + active: Boolean(item.active), + })), + assets: await this.inventory.list(company), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + today: new Date().toISOString().slice(0, 10), + }; + if ( + context.profiles.some( + (profile) => + !( + context.people.some((person) => person.id.equals(profile.values.personId)) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId)) + ) + ) || + context.mappings.some((mapping) => !folderReferences(mapping.values, context)) + ) { + throw new Error("Ámbito de carpeta persistido inconsistente."); + } return Result.ok(context); } - get(company:UniqueID){return this.database.transaction(t=>this.load(company,t,false));} - save(company:UniqueID,action:Parameters[1]){ - return this.database.transaction(async transaction=>{ - const loaded=await this.load(company,transaction,true);if(!loaded.isSuccess){return loaded;} - const candidate=action(loaded.data);if(!candidate.isSuccess){return candidate;} - const previous=loaded.data.mappings.find(item=>item.id.equals(candidate.data.id)); - if(previous!==candidate.data){ - const record=this.mapper.toPersistence(candidate.data); - await this.database.query("INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,:driveKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),driveKey=VALUES(driveKey)",{replacements:record.root,transaction}); - const stored=new Set(previous?.history.map(r=>r.id.toPrimitive())); - for(const revision of record.revisions.filter(r=>!stored.has(r.id))){ - await this.database.query("INSERT INTO folder_mapping_revisions (id,mappingId,position,actorId,recordedAt,`values`) VALUES (:id,:mappingId,:position,:actorId,:recordedAt,:values)",{replacements:revision,transaction}); + get(company: UniqueID) { + return this.database.transaction((t) => this.load(company, t, false)); + } + save(company: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(company, transaction, true); + if (!loaded.isSuccess) { + return loaded; + } + const candidate = action(loaded.data); + if (!candidate.isSuccess) { + return candidate; + } + const previous = loaded.data.mappings.find((item) => item.id.equals(candidate.data.id)); + if (previous !== candidate.data) { + const record = this.mapper.toPersistence(candidate.data); + await this.database.query( + "INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,:driveKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),driveKey=VALUES(driveKey)", + { replacements: record.root, transaction } + ); + const stored = new Set(previous?.history.map((r) => r.id.toPrimitive())); + for (const revision of record.revisions.filter((r) => !stored.has(r.id))) { + await this.database.query( + "INSERT INTO folder_mapping_revisions (id,mappingId,position,actorId,recordedAt,`values`) VALUES (:id,:mappingId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); } } - return Result.ok({mapping:candidate.data,context:loaded.data}); + return Result.ok({ mapping: candidate.data, context: loaded.data }); }); } } diff --git a/modules/companies/src/common/folder-mappings.ts b/modules/companies/src/common/folder-mappings.ts index 4f44a3f..f3f61f1 100644 --- a/modules/companies/src/common/folder-mappings.ts +++ b/modules/companies/src/common/folder-mappings.ts @@ -1,12 +1,33 @@ -import {z} from "zod/v4"; -import {FolderMappingInputSchema} from "../api/domain/folder-mapping-values"; -export {FolderMappingInputSchema,FOLDER_STATE_TRANSITIONS} from "../api/domain/folder-mapping-values"; -export type {FolderMappingInput} from "../api/domain/folder-mapping-values"; -export const SaveFolderMappingRequestSchema=z.strictObject({values:FolderMappingInputSchema}); -export const FolderMappingSchema=z.strictObject({ - id:z.uuid(),companyId:z.uuid(),values:FolderMappingInputSchema,needsReview:z.boolean(),permissionsCovered:z.boolean(), - history:z.array(z.strictObject({id:z.uuid(),actorId:z.uuid(),actorName:z.string().min(1),recordedAt:z.iso.datetime(),values:FolderMappingInputSchema})).min(1), +import { z } from "zod/v4"; + +import { FolderMappingInputSchema } from "../api/domain/folder-mapping-values"; + +export type { FolderMappingInput } from "../api/domain/folder-mapping-values"; +export { + FOLDER_STATE_TRANSITIONS, + FolderMappingInputSchema, +} from "../api/domain/folder-mapping-values"; +export const SaveFolderMappingRequestSchema = z.strictObject({ values: FolderMappingInputSchema }); +export const FolderMappingSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: FolderMappingInputSchema, + needsReview: z.boolean(), + permissionsCovered: z.boolean(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), + values: FolderMappingInputSchema, + }) + ) + .min(1), }); -export const FolderMappingsResponseSchema=z.strictObject({mappings:z.array(FolderMappingSchema)}); -export const FolderMappingResponseSchema=z.strictObject({mapping:FolderMappingSchema}); -export type FolderMappingDTO=z.infer; +export const FolderMappingsResponseSchema = z.strictObject({ + mappings: z.array(FolderMappingSchema), +}); +export const FolderMappingResponseSchema = z.strictObject({ mapping: FolderMappingSchema }); +export type FolderMappingDTO = z.infer; diff --git a/modules/companies/src/web/folders/folder-api.ts b/modules/companies/src/web/folders/folder-api.ts index e8b1a9a..88dfffb 100644 --- a/modules/companies/src/web/folders/folder-api.ts +++ b/modules/companies/src/web/folders/folder-api.ts @@ -1,16 +1,42 @@ -import {z} from "zod/v4"; -import {FolderMappingsResponseSchema,FolderMappingResponseSchema,type FolderMappingInput} from "../../common/folder-mappings"; -export class FolderApiError extends Error{constructor(public readonly code:string){super(code);}} -export const folderKeys={company:(id:string)=>["folder-mappings",id]}; -async function responseData(response:Response){ - const body:unknown=await response.json(); - if(!response.ok){const error=z.object({code:z.string()}).safeParse(body);throw new FolderApiError(error.success ? error.data.code:"INTERNAL_ERROR");} +import { z } from "zod/v4"; + +import { + type FolderMappingInput, + FolderMappingResponseSchema, + FolderMappingsResponseSchema, +} from "../../common/folder-mappings"; +export class FolderApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export const folderKeys = { company: (id: string) => ["folder-mappings", id] }; +async function responseData(response: Response) { + const body: unknown = await response.json(); + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(body); + throw new FolderApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } return body; } -export const folderApi={ - async get(companyId:string){return FolderMappingsResponseSchema.parse(await responseData(await fetch("/api/folder-mappings/"+companyId,{credentials:"same-origin"})));}, - async save(companyId:string,values:FolderMappingInput,csrfToken:string,id?:string){ - return FolderMappingResponseSchema.parse(await responseData(await fetch("/api/folder-mappings/"+companyId+(id ? "/"+id+"/update":""),{ - method:"POST",credentials:"same-origin",headers:{"content-type":"application/json","x-csrf-token":csrfToken},body:JSON.stringify({values})}))); +export const folderApi = { + async get(companyId: string) { + return FolderMappingsResponseSchema.parse( + await responseData( + await fetch(`/api/folder-mappings/${companyId}`, { credentials: "same-origin" }) + ) + ); + }, + async save(companyId: string, values: FolderMappingInput, csrfToken: string, id?: string) { + return FolderMappingResponseSchema.parse( + await responseData( + await fetch(`/api/folder-mappings/${companyId}${id ? `/${id}/update` : ""}`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json", "x-csrf-token": csrfToken }, + body: JSON.stringify({ values }), + }) + ) + ); }, }; diff --git a/modules/companies/src/web/folders/folder-feedback.tsx b/modules/companies/src/web/folders/folder-feedback.tsx index ba24aa5..616c328 100644 --- a/modules/companies/src/web/folders/folder-feedback.tsx +++ b/modules/companies/src/web/folders/folder-feedback.tsx @@ -1,6 +1,11 @@ -import {useTranslation} from "react-i18next"; -import {FolderApiError} from "./folder-api"; -export function FolderFeedback({error}:{error:unknown}){ - const {t}=useTranslation("companies"); - return error ?

{t("folders.errors."+(error instanceof FolderApiError ? error.code:"INTERNAL_ERROR"))}

:null; +import { useTranslation } from "react-i18next"; + +import { FolderApiError } from "./folder-api"; +export function FolderFeedback({ error }: { error: unknown }) { + const { t } = useTranslation("companies"); + return error ? ( +

+ {t(`folders.errors.${error instanceof FolderApiError ? error.code : "INTERNAL_ERROR"}`)} +

+ ) : null; } diff --git a/modules/companies/src/web/folders/folder-mapping-form.tsx b/modules/companies/src/web/folders/folder-mapping-form.tsx index 8b0892c..498dba9 100644 --- a/modules/companies/src/web/folders/folder-mapping-form.tsx +++ b/modules/companies/src/web/folders/folder-mapping-form.tsx @@ -1,106 +1,260 @@ -import {zodResolver} from "@hookform/resolvers/zod"; -import {Button} from "@repo/shadcn-ui/components/button"; -import {ContextHelp} from "@repo/shadcn-ui/components/context-help"; -import {FormActions,FormDialog} from "@repo/shadcn-ui/components/form-dialog"; -import {Input} from "@repo/shadcn-ui/components/input"; -import {Label} from "@repo/shadcn-ui/components/label"; -import {SelectWithAction} from "@repo/shadcn-ui/components/select-with-action"; -import {Textarea} from "@repo/shadcn-ui/components/textarea"; -import {useMutation,useQuery,useQueryClient} from "@tanstack/react-query"; -import {useEffect,useId,useRef,useState} from "react"; -import {useForm} from "react-hook-form"; -import {useTranslation} from "react-i18next"; -import type {CompanyDTO,AccessItemDTO,AccessResponseDTO,WorkProfileDTO} from "../../common"; -import {FolderMappingInputSchema,FOLDER_STATE_TRANSITIONS,type FolderMappingInput,type FolderMappingDTO} from "../../common/folder-mappings"; -import {accessApi,accessKeys} from "../access/access-api"; -import {AccessForm} from "../access/access-form"; -import {accessLabel} from "../access/access-labels"; -import {profileApi,profileKeys} from "../profiles/work-profile-api"; -import {WorkProfileForm} from "../profiles/work-profile-form"; -import {folderApi,folderKeys} from "./folder-api"; -import {FolderFeedback} from "./folder-feedback"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; +import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useId, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; -export function FolderMappingForm({company,csrfToken,mapping,onSaved,initialProfileId=""}:{ - company:CompanyDTO;csrfToken:string;mapping?:FolderMappingDTO|undefined;onSaved:()=>void;initialProfileId?:string; -}){ - const {t}=useTranslation("companies"),client=useQueryClient(),prefix=useId(),mounted=useRef(true); - useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]); - const [quick,setQuick]=useState<"profile"|"need">(); - const access=useQuery({queryKey:accessKeys.scope(company.id),queryFn:()=>accessApi.request(company.id)}); - const profiles=useQuery({queryKey:profileKeys.company(company.id),queryFn:()=>profileApi.get(company.id)}); - const form=useForm({resolver:zodResolver(FolderMappingInputSchema),defaultValues:mapping?.values ?? { - profileId:initialProfileId,needId:"",path:"",driveLetter:null,state:"pending",notes:null}}); - const values=form.watch(),items=access.data?.items ?? [],profile=profiles.data?.profiles.find(item=>item.id===values.profileId); - const currentPath=pathForNeed(items,values.needId); - const changeNeed=(id:string)=>{form.setValue("needId",id);form.setValue("path",pathForNeed(items,id));}; - const mutation=useMutation({mutationFn:(input:FolderMappingInput)=>folderApi.save(company.id,input,csrfToken,mapping?.id), - onSuccess:async()=>{await client.invalidateQueries({queryKey:folderKeys.company(company.id)});if(mounted.current){onSaved();}}}); - const ready=[access.data,profiles.data].every(Boolean); - const needs=items.filter(item=>item.values.kind==="need" && (item.values.personId===profile?.values.personId || item.id===values.needId)); - const states=mapping ? FOLDER_STATE_TRANSITIONS[mapping.values.state]:["pending","configured"]; - return <> -
mutation.mutate(input))}> -
-
-
- item.values.active || item.id===values.profileId).map(item=>({value:item.id,label:item.values.name}))} - action={mapping || initialProfileId ? undefined:{label:t("profiles.new"),onClick:()=>setQuick("profile")}} - onValueChange={id=>{form.setValue("profileId",id);changeNeed("");}} />
-
- ({value:item.id,label:accessLabel(item,items,company.people)}))} - action={mapping ? undefined:{label:t("folders.newNeed"),onClick:()=>setQuick("need")}} onValueChange={changeNeed} />
-
-
-
- {mapping && currentPath!==values.path &&
-

{t("folders.currentPath")}: {currentPath}

- -
} -
-
- form.setValue("driveLetter",event.target.value || null)} placeholder="G:" />
-
- ({value:state,label:t("mail."+state)}))} onValueChange={state=>{ - const parsed=FolderMappingInputSchema.shape.state.safeParse(state);if(parsed.success){form.setValue("state",parsed.data);} - }} />
-
+import type { AccessItemDTO, AccessResponseDTO, CompanyDTO } from "../../common"; +import { + FOLDER_STATE_TRANSITIONS, + type FolderMappingDTO, + type FolderMappingInput, + FolderMappingInputSchema, +} from "../../common/folder-mappings"; +import { accessApi, accessKeys } from "../access/access-api"; +import { accessLabel } from "../access/access-labels"; +import { profileApi, profileKeys } from "../profiles/work-profile-api"; -
-