refactor: improve folder mapping persistence and API integration

- Enhanced the folder mapping persistence mapper for better error handling and data validation.
- Updated the Sequelize folder mapping repository to streamline data loading and improve transaction handling.
- Refactored folder mapping schemas for clarity and consistency in validation.
- Improved folder API error handling and response data processing.
- Enhanced folder mapping form component with better state management and user feedback.
- Introduced a new quick editor component for creating profiles and needs within the folder mapping context.
This commit is contained in:
David Arranz 2026-10-08 13:48:10 +02:00
parent fbfa64509b
commit 78c05eae6d
20 changed files with 1038 additions and 389 deletions

View File

@ -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;

View File

@ -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.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));
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,
])
);
}

View File

@ -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(),
})),
};
}

View File

@ -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<string,string>;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<string, string>;
today: string;
}>;
export interface IFolderMappingRepository{
get(company:UniqueID):Promise<Result<FolderContext,FolderMappingError>>;
save(company:UniqueID,action:(context:FolderContext)=>Result<FolderMapping,FolderMappingError>):
Promise<Result<{mapping:FolderMapping;context:FolderContext},FolderMappingError>>;
export interface IFolderMappingRepository {
get(company: UniqueID): Promise<Result<FolderContext, FolderMappingError>>;
save(
company: UniqueID,
action: (context: FolderContext) => Result<FolderMapping, FolderMappingError>
): Promise<Result<{ mapping: FolderMapping; context: FolderContext }, FolderMappingError>>;
}

View File

@ -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";

View File

@ -15,10 +15,10 @@ type Reason =
| "inactive_catalog";
type Coverage = { needId: string; covered: boolean; missingPermissionIds: string[] };
class AccessProjection {
private readonly entries: ReadonlyMap<string, Pick<AccessItem,"id"|"values">>;
private readonly entries: ReadonlyMap<string, Pick<AccessItem, "id" | "values">>;
constructor(
private readonly items: readonly Pick<AccessItem,"id"|"values">[],
private readonly people: readonly Pick<Person,"id"|"active">[],
private readonly items: readonly Pick<AccessItem, "id" | "values">[],
private readonly people: readonly Pick<Person, "id" | "active">[],
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<AccessItem,"id"|"values">) => {
authorizedLink = (item: Pick<AccessItem, "id" | "values">) => {
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<AccessItem,"id"|"values">) => {
validLink = (item: Pick<AccessItem, "id" | "values">) => {
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<AccessItem,"id"|"values">): Coverage[] {
needCoverage(item: Pick<AccessItem, "id" | "values">): Coverage[] {
const value = item.values.data;
if (value.kind !== "need") {
return [];
@ -122,7 +122,7 @@ class AccessProjection {
];
}
private accountReview(
item: Pick<AccessItem,"id"|"values">,
item: Pick<AccessItem, "id" | "values">,
value: Extract<AccessDefinition, { kind: "account" }>
): Reason[] {
const reasons: Reason[] = [];
@ -158,7 +158,7 @@ class AccessProjection {
return reasons;
}
private grantReview(
item: Pick<AccessItem,"id"|"values">,
item: Pick<AccessItem, "id" | "values">,
value: Extract<AccessDefinition, { kind: "grant" }>
): Reason[] {
if (value.endDate !== null) {
@ -187,7 +187,7 @@ class AccessProjection {
return reasons;
}
private needReview(
item: Pick<AccessItem,"id"|"values">,
item: Pick<AccessItem, "id" | "values">,
value: Extract<AccessDefinition, { kind: "need" }>,
coverage: readonly Coverage[]
): Reason[] {
@ -203,7 +203,10 @@ class AccessProjection {
}
return reasons;
}
private specificReview(item: Pick<AccessItem,"id"|"values">, coverage: readonly Coverage[]): Reason[] {
private specificReview(
item: Pick<AccessItem, "id" | "values">,
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<AccessItem,"id"|"values">[],
people: readonly Pick<Person,"id"|"active">[],
items: readonly Pick<AccessItem, "id" | "values">[],
people: readonly Pick<Person, "id" | "active">[],
today: string
) {
return new AccessProjection(items, people, today).project();

View File

@ -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<typeof FolderMappingInputSchema>;
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<typeof FolderMappingInputSchema>;
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;

View File

@ -77,10 +77,7 @@ export class FolderMapping extends AggregateRoot<Props> {
{
...this.props,
values,
history: [
...this.history,
FolderMappingRevision.create({ values, actorId, recordedAt }),
],
history: [...this.history, FolderMappingRevision.create({ values, actorId, recordedAt })],
},
this.id
)

View File

@ -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<DriveLetter,FolderMappingError>{
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<DriveLetter, FolderMappingError> {
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;}
}

View File

@ -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<DriveLetter>,public readonly state:FolderMappingInput["state"],public readonly notes:Maybe<string>){Object.freeze(this);}
static create(input:FolderMappingInput):Result<FolderMappingValues,FolderMappingError>{
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<DriveLetter>,
public readonly state: FolderMappingInput["state"],
public readonly notes: Maybe<string>
) {
Object.freeze(this);
}
static create(input: FolderMappingInput): Result<FolderMappingValues, FolderMappingError> {
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};}
}

View File

@ -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<SharedFolderPath,FolderMappingError>{
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<SharedFolderPath, FolderMappingError> {
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();
}
}

View File

@ -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() })

View File

@ -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"});}
}

View File

@ -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}`,
};
}

View File

@ -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<Result<FolderContext,FolderMappingError>>{
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<FolderRow>("SELECT * FROM folder_mappings WHERE companyId=:companyId",{...options,type:QueryTypes.SELECT});
const revisions=await this.database.query<FolderRevisionRow>("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<ProfileRow>("SELECT * FROM work_profiles WHERE companyId=:companyId",{...options,type:QueryTypes.SELECT});
const profileRevisions=await this.database.query<ProfileRevisionRow>("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<Result<FolderContext, FolderMappingError>> {
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<FolderRow>(
"SELECT * FROM folder_mappings WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<FolderRevisionRow>(
"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<ProfileRow>(
"SELECT * FROM work_profiles WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
);
const profileRevisions = await this.database.query<ProfileRevisionRow>(
"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<IFolderMappingRepository["save"]>[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<IFolderMappingRepository["save"]>[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 });
});
}
}

View File

@ -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<typeof FolderMappingSchema>;
export const FolderMappingsResponseSchema = z.strictObject({
mappings: z.array(FolderMappingSchema),
});
export const FolderMappingResponseSchema = z.strictObject({ mapping: FolderMappingSchema });
export type FolderMappingDTO = z.infer<typeof FolderMappingSchema>;

View File

@ -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 }),
})
)
);
},
};

View File

@ -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 ? <p className="text-sm text-destructive" role="alert">{t("folders.errors."+(error instanceof FolderApiError ? error.code:"INTERNAL_ERROR"))}</p>:null;
import { useTranslation } from "react-i18next";
import { FolderApiError } from "./folder-api";
export function FolderFeedback({ error }: { error: unknown }) {
const { t } = useTranslation("companies");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`folders.errors.${error instanceof FolderApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}

View File

@ -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<FolderMappingInput>({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 <>
<form className="space-y-5" onSubmit={form.handleSubmit(input=>mutation.mutate(input))}>
<fieldset disabled={mutation.isPending} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label htmlFor={prefix+"-profile"}>{t("mail.profile")}</Label>
<SelectWithAction id={prefix+"-profile"} label={t("mail.profile")} value={values.profileId} required
disabled={!!mapping || !!initialProfileId || !ready} placeholder={t("mail.choose")}
options={(profiles.data?.profiles ?? []).filter(item=>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("");}} /></div>
<div className="space-y-2"><Label htmlFor={prefix+"-need"}>{t("folders.need")}</Label>
<SelectWithAction id={prefix+"-need"} label={t("folders.need")} value={values.needId} required placeholder={t("mail.choose")}
disabled={!!mapping || !profile || !ready} options={needs.map(item=>({value:item.id,label:accessLabel(item,items,company.people)}))}
action={mapping ? undefined:{label:t("folders.newNeed"),onClick:()=>setQuick("need")}} onValueChange={changeNeed} /></div>
</div>
<div className="space-y-2"><Label htmlFor={prefix+"-path"}>{t("folders.path")}</Label>
<Input id={prefix+"-path"} readOnly value={values.path} /></div>
{mapping && currentPath!==values.path && <div className="rounded-md border p-3 text-sm space-y-2">
<p>{t("folders.currentPath")}: {currentPath}</p>
<Button type="button" variant="outline" onClick={()=>form.setValue("path",currentPath)}>{t("folders.useCurrentPath")}</Button>
</div>}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label htmlFor={prefix+"-letter"}>{t("folders.letter")}</Label>
<Input id={prefix+"-letter"} maxLength={2} value={values.driveLetter ?? ""} onChange={event=>form.setValue("driveLetter",event.target.value || null)} placeholder="G:" /></div>
<div className="space-y-2"><Label htmlFor={prefix+"-state"}>{t("mail.state")}</Label>
<SelectWithAction id={prefix+"-state"} label={t("mail.state")} value={values.state} required placeholder={t("mail.choose")}
options={states.map(state=>({value:state,label:t("mail."+state)}))} onValueChange={state=>{
const parsed=FolderMappingInputSchema.shape.state.safeParse(state);if(parsed.success){form.setValue("state",parsed.data);}
}} /></div>
</div>
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";
<div className="space-y-2"><Label htmlFor={prefix+"-notes"}>{t("mail.notes")}</Label>
<Textarea id={prefix+"-notes"} rows={3} maxLength={4000} value={values.notes ?? ""} onChange={event=>form.setValue("notes",event.target.value || null)} /></div>
</fieldset>
{Object.keys(form.formState.errors).length>0 && <p role="alert">{t("folders.errors.INVALID_REQUEST")}</p>}
<FolderFeedback error={mutation.error ?? access.error ?? profiles.error} />
<ContextHelp title={t("folders.help")} description={t("folders.description")} guidance={t("folders.guidance")}
examples={t("folders.examples")} examplesLabel={t("mail.examplesLabel")} scopeNote={t("folders.scope")} />
<FormActions><Button disabled={mutation.isPending || !ready} type="submit">{t(mutation.isPending ? "saving":"save")}</Button></FormActions>
</form>
{quick && <FolderQuickEditor kind={quick} company={company} csrfToken={csrfToken} data={access.data} profile={profile}
onClose={()=>setQuick(undefined)} onCreated={(field,id)=>{if(mounted.current){form.setValue(field,id);setQuick(undefined);
if(field==="needId"){const updated=client.getQueryData<AccessResponseDTO>(accessKeys.scope(company.id));
form.setValue("path",pathForNeed(updated?.items ?? items,id));}
else {changeNeed("");}
}}} />}
</>;
import { folderApi, folderKeys } from "./folder-api";
import { FolderFeedback } from "./folder-feedback";
import { FolderQuickEditor } from "./folder-quick-editor";
export function FolderMappingForm({
company,
csrfToken,
mapping,
onSaved,
initialProfileId = "",
}: {
company: CompanyDTO;
csrfToken: string;
mapping?: FolderMappingDTO | undefined;
onSaved: () => void;
initialProfileId?: string;
}) {
const { t } = useTranslation("companies");
const client = useQueryClient();
const prefix = useId();
const 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<FolderMappingInput>({
resolver: zodResolver(FolderMappingInputSchema),
defaultValues: mapping?.values ?? {
profileId: initialProfileId,
needId: "",
path: "",
driveLetter: null,
state: "pending",
notes: null,
},
});
const values = form.watch();
const items = access.data?.items ?? [];
const 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"];
const handleQuickCreated = (field: "profileId" | "needId", id: string) => {
if (!mounted.current) {
return;
}
form.setValue(field, id);
setQuick(undefined);
if (field === "needId") {
const updated = client.getQueryData<AccessResponseDTO>(accessKeys.scope(company.id));
form.setValue("path", pathForNeed(updated?.items ?? items, id));
return;
}
changeNeed("");
};
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
mapping || initialProfileId
? undefined
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
}
disabled={!!mapping || !!initialProfileId || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => {
form.setValue("profileId", id);
changeNeed("");
}}
options={(profiles.data?.profiles ?? [])
.filter((item) => item.values.active || item.id === values.profileId)
.map((item) => ({ value: item.id, label: item.values.name }))}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-need`}>{t("folders.need")}</Label>
<SelectWithAction
action={
mapping
? undefined
: { label: t("folders.newNeed"), onClick: () => setQuick("need") }
}
disabled={!!mapping || !profile || !ready}
id={`${prefix}-need`}
label={t("folders.need")}
onValueChange={changeNeed}
options={needs.map((item) => ({
value: item.id,
label: accessLabel(item, items, company.people),
}))}
placeholder={t("mail.choose")}
required
value={values.needId}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-path`}>{t("folders.path")}</Label>
<Input id={`${prefix}-path`} readOnly value={values.path} />
</div>
{mapping && currentPath !== values.path && (
<div className="rounded-md border p-3 text-sm space-y-2">
<p>
{t("folders.currentPath")}: {currentPath}
</p>
<Button
onClick={() => form.setValue("path", currentPath)}
type="button"
variant="outline"
>
{t("folders.useCurrentPath")}
</Button>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-letter`}>{t("folders.letter")}</Label>
<Input
id={`${prefix}-letter`}
maxLength={2}
onChange={(event) => form.setValue("driveLetter", event.target.value || null)}
placeholder="G:"
value={values.driveLetter ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("mail.state")}
onValueChange={(state) => {
const parsed = FolderMappingInputSchema.shape.state.safeParse(state);
if (parsed.success) {
form.setValue("state", parsed.data);
}
}}
options={states.map((state) => ({ value: state, label: t(`mail.${state}`) }))}
placeholder={t("mail.choose")}
required
value={values.state}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
onChange={(event) => form.setValue("notes", event.target.value || null)}
rows={3}
value={values.notes ?? ""}
/>
</div>
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p role="alert">{t("folders.errors.INVALID_REQUEST")}</p>
)}
<FolderFeedback error={mutation.error ?? access.error ?? profiles.error} />
<ContextHelp
description={t("folders.description")}
examples={t("folders.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("folders.guidance")}
scopeNote={t("folders.scope")}
title={t("folders.help")}
/>
<FormActions>
<Button disabled={mutation.isPending || !ready} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<FolderQuickEditor
company={company}
csrfToken={csrfToken}
data={access.data}
kind={quick}
onClose={() => setQuick(undefined)}
onCreated={handleQuickCreated}
profile={profile}
/>
)}
</>
);
}
function pathForNeed(items:readonly AccessItemDTO[],id:string){
const need=items.find(item=>item.id===id)?.values;
if(need?.kind!=="need"){return "";}
const resource=items.find(item=>item.id===need.resourceId)?.values;
return resource?.kind==="resource" ? resource.reference ?? "":"";
}
function FolderQuickEditor({kind,company,csrfToken,data,profile,onClose,onCreated}:{
kind:"profile"|"need";company:CompanyDTO;csrfToken:string;data:AccessResponseDTO|undefined;profile:WorkProfileDTO|undefined;
onClose:()=>void;onCreated:(field:"profileId"|"needId",id:string)=>void;
}){
const {t}=useTranslation("companies");
return <FormDialog open formActions wide title={t(kind==="profile" ? "profiles.new":"folders.newNeed")} cancelLabel={t("cancel")}
onOpenChange={open=>{if(!open){onClose();}}}>
{kind==="profile" && <WorkProfileForm company={company} csrfToken={csrfToken} allowQuickCreate={false} onSaved={id=>onCreated("profileId",id)} />}
{kind==="need" && data && profile && <AccessForm scope={company.id} kind="need" data={data} people={company.people} csrfToken={csrfToken}
initialValues={{personId:profile.values.personId}} lockedFields={["personId"]} allowQuickCreate={false} onSaved={id=>{if(id){onCreated("needId",id);}}} />}
</FormDialog>;
function pathForNeed(items: readonly AccessItemDTO[], id: string) {
const need = items.find((item) => item.id === id)?.values;
if (need?.kind !== "need") {
return "";
}
const resource = items.find((item) => item.id === need.resourceId)?.values;
return resource?.kind === "resource" ? (resource.reference ?? "") : "";
}

View File

@ -0,0 +1,66 @@
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { AccessResponseDTO, CompanyDTO, WorkProfileDTO } from "../../common";
import { AccessForm } from "../access/access-form";
import { WorkProfileForm } from "../profiles/work-profile-form";
export function FolderQuickEditor({
kind,
company,
csrfToken,
data,
profile,
onClose,
onCreated,
}: {
kind: "profile" | "need";
company: CompanyDTO;
csrfToken: string;
data: AccessResponseDTO | undefined;
profile: WorkProfileDTO | undefined;
onClose: () => void;
onCreated: (field: "profileId" | "needId", id: string) => void;
}) {
const { t } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(kind === "profile" ? "profiles.new" : "folders.newNeed")}
wide
>
{kind === "profile" && (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onCreated("profileId", id)}
/>
)}
{kind === "need" && data && profile && (
<AccessForm
allowQuickCreate={false}
csrfToken={csrfToken}
data={data}
initialValues={{ personId: profile.values.personId }}
kind="need"
lockedFields={["personId"]}
onSaved={(id) => {
if (id) {
onCreated("needId", id);
}
}}
people={company.people}
scope={company.id}
/>
)}
</FormDialog>
);
}