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:
parent
fbfa64509b
commit
78c05eae6d
@ -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;
|
||||
|
||||
@ -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,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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;}
|
||||
}
|
||||
|
||||
@ -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};}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() })
|
||||
|
||||
@ -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"});}
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 }),
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 ?? "") : "";
|
||||
}
|
||||
|
||||
66
modules/companies/src/web/folders/folder-quick-editor.tsx
Normal file
66
modules/companies/src/web/folders/folder-quick-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user