Carpetas compartidas del perfil

This commit is contained in:
David Arranz 2026-10-05 12:02:04 +02:00
parent 6375fa1b32
commit 6ab7e8f846
31 changed files with 824 additions and 20 deletions

View File

@ -45,6 +45,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM mailbox_uses");
await database.query("DELETE FROM mailbox_revisions");
await database.query("DELETE FROM mailboxes");
await database.query("DELETE FROM folder_mapping_revisions");
await database.query("DELETE FROM folder_mappings");
await database.query("DELETE FROM work_profile_revisions");
await database.query("DELETE FROM work_profiles");
await database.query("DELETE FROM workstation_assets");

View File

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"dev": "vite --host --clearScreen false",
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx",
"build": "vite build",
@ -36,4 +36,4 @@
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
}

View File

@ -5,8 +5,9 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
strictPort: true,
host: true,
//port: 5173,
//strictPort: true,
proxy: { "/api": process.env.API_PROXY_TARGET ?? "http://127.0.0.1:3001" },
fs: { strict: true },
},

View File

@ -21,6 +21,12 @@ Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inici
Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env.
## Acceso desde la red local
En este equipo se utiliza http://192.168.0.2:5173. Vite usa server.host: true y la orden de desarrollo respeta esa configuración. En WSL2 con NAT, esto permite conexiones dentro de la red de WSL, pero no elimina la necesidad de reenviar el puerto desde Windows. El script activar.ps1 obtiene la IPv4 actual de Ubuntu-24.04 y configura el reenvío desde 192.168.0.2:5173 hacia esa dirección en WSL. La API y MariaDB siguen escuchando únicamente en loopback. La regla de cortafuegos se limita al perfil privado y a equipos de la subred local.
Ejecutar activar.ps1 desde PowerShell como administrador y mantener pnpm dev en WSL. Los scripts activar.ps1 y desactivar.ps1 están en la carpeta metis-desk-lan del espacio de trabajo de Windows. Para acceder y guardar datos desde la dirección de red, establecer APP_ORIGIN=http://192.168.0.2:5173 en .env y reiniciar pnpm dev. Solo se permite un origen: con esa configuración, las operaciones desde localhost serán rechazadas. Si cambia la dirección de este ordenador, retirar el reenvío anterior, actualizar los scripts y APP_ORIGIN y volver a activarlo. La activación requiere privilegios de Windows. El reenvío y la regla privada están activados y se ha comprobado la respuesta HTTP y la sesión por la dirección LAN. Si WSL cambia de dirección después de reiniciar, volver a ejecutar activar.ps1 para actualizar el destino. La comprobación desde un segundo equipo depende de disponer de ese equipo en la red.
## Verificaciones
```bash

View File

@ -0,0 +1,29 @@
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))){
return Result.fail(new FolderMappingError("DRIVE_LETTER_IN_USE"));
}
return mapped;
}

View File

@ -0,0 +1,33 @@
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));
}
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]));
}

View File

@ -0,0 +1,12 @@
import type {FolderMappingDTO} from "../../common/folder-mappings";
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()}))};
}

View File

@ -0,0 +1,17 @@
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>>;
}

View File

@ -0,0 +1,48 @@
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 type { IFolderMappingRepository } from "./folder-mapping.repository";
import { mapFolderInput } from "./folder-mapping-input.mapper";
export class FolderMappingUseCases {
constructor(private readonly repository: IFolderMappingRepository) {}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess
? this.repository.get(company.data)
: Promise.resolve(Result.fail(new FolderMappingError("INVALID_REQUEST")));
}
save(companyId: string, id: string | undefined, input: FolderMappingInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined;
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
return Promise.resolve(Result.fail(new FolderMappingError("INVALID_REQUEST")));
}
return this.repository.save(company.data, (context) => {
const previous = identity?.isSuccess
? context.mappings.find((item) => item.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new FolderMappingError("FOLDER_MAPPING_NOT_FOUND"));
}
const values = mapFolderInput(input, context, previous);
if (!values.isSuccess) {
return values;
}
const now = new Date().toISOString();
const candidate = previous
? previous.update(values.data, actor.data, now)
: FolderMapping.create(company.data, values.data, actor.data, now);
if (!candidate.isSuccess) {
return candidate;
}
return candidate;
});
}
}

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, AccessItem>;
private readonly entries: ReadonlyMap<string, Pick<AccessItem,"id"|"values">>;
constructor(
private readonly items: readonly AccessItem[],
private readonly people: readonly Person[],
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: AccessItem) => {
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: AccessItem) => {
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: AccessItem): 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: AccessItem,
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: AccessItem,
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: AccessItem,
item: Pick<AccessItem,"id"|"values">,
value: Extract<AccessDefinition, { kind: "need" }>,
coverage: readonly Coverage[]
): Reason[] {
@ -203,7 +203,7 @@ class AccessProjection {
}
return reasons;
}
private specificReview(item: AccessItem, 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 +254,8 @@ class AccessProjection {
}
}
export function projectAccess(
items: readonly AccessItem[],
people: readonly Person[],
items: readonly Pick<AccessItem,"id"|"values">[],
people: readonly Pick<Person,"id"|"active">[],
today: string
) {
return new AccessProjection(items, people, today).project();

View File

@ -0,0 +1,25 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { FolderMappingValues } from "./value-objects/folder-mapping-values";
export type FolderMappingRevisionProps = Readonly<{
values: FolderMappingValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class FolderMappingRevision extends DomainEntity<FolderMappingRevisionProps> {
static create(props: FolderMappingRevisionProps) {
return new FolderMappingRevision(Object.freeze({ ...props }));
}
static rehydrate(props: FolderMappingRevisionProps, id: UniqueID) {
return new FolderMappingRevision(Object.freeze({ ...props }), id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}
}

View File

@ -0,0 +1,15 @@
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"],
} as const;

View File

@ -0,0 +1,89 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { FolderMappingRevision } from "./folder-mapping-revision.entity";
import { FOLDER_STATE_TRANSITIONS, FolderMappingError } from "./folder-mapping-values";
import type { FolderMappingValues } from "./value-objects/folder-mapping-values";
type Props = Readonly<{
companyId: UniqueID;
values: FolderMappingValues;
history: readonly FolderMappingRevision[];
}>;
export class FolderMapping extends AggregateRoot<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: UniqueID,
values: FolderMappingValues,
actorId: UniqueID,
recordedAt: string
): Result<FolderMapping, FolderMappingError> {
if (
!(
z.iso.datetime().safeParse(recordedAt).success &&
["pending", "configured"].includes(values.state)
)
) {
return Result.fail(new FolderMappingError("INVALID_FOLDER_TRANSITION"));
}
return Result.ok(
new FolderMapping({
companyId,
values,
history: [FolderMappingRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: Props, id: UniqueID) {
return new FolderMapping(props, id);
}
get companyId() {
return this.props.companyId;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: FolderMappingValues,
actorId: UniqueID,
recordedAt: string
): Result<FolderMapping, FolderMappingError> {
if (this.values.equals(values)) {
return Result.ok(this);
}
if (!this.values.sameTarget(values)) {
return Result.fail(new FolderMappingError("FOLDER_TARGET_IMMUTABLE"));
}
if (
!FOLDER_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) ||
(this.values.closed && !this.values.equals(values))
) {
return Result.fail(new FolderMappingError("INVALID_FOLDER_TRANSITION"));
}
if (
!z.iso.datetime().safeParse(recordedAt).success ||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
) {
return Result.fail(new FolderMappingError("INVALID_REQUEST"));
}
return Result.ok(
new FolderMapping(
{
...this.props,
values,
history: [
...this.history,
FolderMappingRevision.create({ values, actorId, recordedAt }),
],
},
this.id
)
);
}
}

View File

@ -0,0 +1,11 @@
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;}
}

View File

@ -0,0 +1,25 @@
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()));
}
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

@ -0,0 +1,17 @@
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())){
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();}
}

View File

@ -0,0 +1,79 @@
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 type { FolderMappingUseCases } from "../../application/folder-mapping.use-cases";
import { buildFolderResponse } from "../../application/folder-mapping-response.builder";
function fail(response: Response, code: string) {
response
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
.json({ code, requestId: response.getHeader("x-request-id") });
}
export function registerFolderMappingRoutes(
app: Application,
useCases: FolderMappingUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId", (request, response, next) => {
const params = MailParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.get(params.data.companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({
mappings: result.data.mappings.map((item) => buildFolderResponse(item, result.data)),
});
})
.catch(next);
});
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 body = SaveFolderMappingRequestSchema.safeParse(request.body);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (
!(
params.success &&
body.success &&
actor.success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
const id =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases
.save(params.data.companyId, id, body.data.values, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.status(id ? 200 : 201).json({
mapping: buildFolderResponse(result.data.mapping, result.data.context),
});
})
.catch(next);
});
}
app.use("/api/folder-mappings", authenticate, router);
}

View File

@ -0,0 +1,17 @@
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}});
}
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

@ -0,0 +1,46 @@
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;
}
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)){
throw new Error("Historial de carpeta persistido inconsistente.");
}
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.");}
}
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())}))};
}
}
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

@ -0,0 +1,55 @@
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.");}
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});
}
}
return Result.ok({mapping:candidate.data,context:loaded.data});
});
}
}

View File

@ -1,3 +1,7 @@
import {FolderMappingUseCases} from "./application/folder-mapping.use-cases";
import {registerFolderMappingRoutes} from "./infrastructure/http/folder-mapping.routes";
import {migrateFolderMappings} from "./infrastructure/persistence/folder-mapping-migration";
import {SequelizeFolderMappingRepository} from "./infrastructure/persistence/sequelize-folder-mapping.repository";
import type { RequestHandler } from "express";
import type { Sequelize } from "sequelize";
@ -46,12 +50,16 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
return {
initializeProfiles: async (inventory: IWorkProfileInventoryService) => {
await migrateWorkProfiles(database);
await migrateFolderMappings(database);
const folders=new FolderMappingUseCases(new SequelizeFolderMappingRepository(database,inventory));
const profiles = new WorkProfileUseCases(
new SequelizeWorkProfileRepository(database, inventory)
);
return {
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) =>
registerWorkProfileRoutes(app, profiles, authenticate),
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
registerWorkProfileRoutes(app, profiles, authenticate);
registerFolderMappingRoutes(app, folders, authenticate);
},
};
},
initializeMail: async (inventory: IMailInventoryService) => {

View File

@ -0,0 +1,12 @@
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),
});
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

@ -8,3 +8,5 @@ export * from "./dto/shared/people.dto";
export * from "./mail-configurations";
export * from "./mailboxes";
export * from "./work-profiles";
export * from "./folder-mappings";

View File

@ -1,3 +1,4 @@
import {FolderMappingsPage} from "../folders/folder-mappings-page";
import { AssetCatalogPage } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
@ -111,6 +112,7 @@ type CompanyTab =
| "assets"
| "inventory"
| "workstations"
| "folders"
| "mailboxes"
| "mail"
| "profiles";
@ -211,6 +213,7 @@ export function CompanyDetail({
],
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
["access", accessTranslation("title"), KeyRoundIcon],
["folders",t("folders.title"),MailIcon],
["mailboxes", t("mailbox.title"), MailIcon],
["mail", t("mail.title"), MailIcon],
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
@ -260,7 +263,9 @@ function CompanySection({
);
case "profiles":
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
case "mailboxes":
case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
case "mailboxes":
return <MailboxesPage company={company} csrfToken={csrfToken} />;
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;

View File

@ -0,0 +1,16 @@
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");}
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})})));
},
};

View File

@ -0,0 +1,6 @@
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

@ -0,0 +1,38 @@
export const foldersSpanish={
title:"Carpetas del perfil",new:"Configurar carpeta",edit:"Editar configuración de carpeta",empty:"Todavía no hay carpetas configuradas en perfiles.",
search:"Buscar carpeta, ruta o perfil",need:"Necesidad de acceso",newNeed:"Nueva necesidad de acceso",path:"Ruta compartida",letter:"Letra de unidad (opcional)",noLetter:"Acceso por ruta, sin letra",currentPath:"Ruta actual del recurso",useCurrentPath:"Utilizar la ruta actual",
fixed:"Perfil y necesidad quedan fijados desde el alta.",permissions:"Permisos necesarios",covered:"Permisos con cobertura registrada",uncovered:"Hay permisos pendientes de cubrir o revisar.",
review:"Revisar: cambió la ruta, terminó la necesidad de acceso o alguno de sus elementos ya no está disponible.",
help:"¿Para qué sirve esta configuración?",description:"Registra cómo accede una persona a una carpeta compartida desde un perfil concreto. La necesidad de acceso aporta el recurso y sus permisos; esta ficha documenta la configuración del equipo.",
guidance:"La referencia del recurso debe contener una ruta UNC de servidor y carpeta compartida. La letra es opcional y no puede repetirse entre configuraciones abiertas del mismo perfil. Una carpeta mapeada no demuestra que se hayan concedido sus permisos.",
examples:"General en SOLETE01, mapeada como G:; Administración como H:; Pedidos como P:. También puedes acceder directamente por la ruta, sin letra de unidad.",
scope:"Los cambios conservan ruta, letra, estado, fecha y técnico. Retirar documenta lo comprobado; Metis Desk no cambia permisos ni desmonta unidades en los equipos.",
errors:{
INVALID_REQUEST:"Revisa los campos del formulario.",INVALID_FOLDER_PATH:"Introduce una ruta UNC válida en la referencia del recurso.",INVALID_DRIVE_LETTER:"La letra debe ser una sola letra, de A a Z.",
FOLDER_SCOPE_MISMATCH:"El perfil y la necesidad deben pertenecer a la compañía y a la misma persona.",FOLDER_TARGET_IMMUTABLE:"Para cambiar perfil o necesidad, crea otra configuración.",
FOLDER_UNAVAILABLE:"El perfil, el recurso y la necesidad deben estar vigentes. La ruta debe coincidir con la referencia actual del recurso.",
FOLDER_MAPPING_EXISTS:"Ya hay una configuración abierta de esta carpeta en el perfil.",DRIVE_LETTER_IN_USE:"Esta letra ya está reservada por otra carpeta de este perfil.",
INVALID_FOLDER_TRANSITION:"El estado no permite ese cambio. Una carpeta configurada pasa primero a pendiente de retirar.",
FOLDER_MAPPING_NOT_FOUND:"La configuración no está disponible.",COMPANY_NOT_FOUND:"La compañía no está disponible.",INTERNAL_ERROR:"No se ha podido completar la operación. Inténtalo de nuevo.",
UNAUTHENTICATED:"La sesión ha caducado.",FORBIDDEN:"Recarga la página para autorizar la operación."
},
};
export const foldersEnglish={
title:"Profile folders",new:"Configure folder",edit:"Edit folder configuration",empty:"No folders configured in profiles yet.",
search:"Search folder, path or profile",need:"Access requirement",newNeed:"New access requirement",path:"Shared path",letter:"Drive letter (optional)",noLetter:"Direct path access, no drive letter",currentPath:"Current resource path",useCurrentPath:"Use current path",
fixed:"Profile and access requirement are fixed after creation.",permissions:"Required permissions",covered:"Permissions have recorded coverage",uncovered:"Some permissions need coverage or review.",
review:"Review: the path changed, the access requirement ended or a referenced item is unavailable.",
help:"What is this configuration for?",description:"Records how a person accesses a shared folder from a specific profile. The access requirement defines the resource and permissions; this record documents device configuration.",
guidance:"The resource reference must contain a UNC server and share path. The drive letter is optional and cannot repeat among open configurations of the same profile. A mapped folder does not prove permissions were granted.",
examples:"General on SOLETE01 mapped as G:; Administration as H:; Orders as P:. Direct path access without a drive letter is also supported.",
scope:"Changes retain path, letter, status, date and technician. Removal records verified work; Metis Desk does not change permissions or unmount device drives.",
errors:{
INVALID_REQUEST:"Check the form fields.",INVALID_FOLDER_PATH:"Enter a valid UNC path in the resource reference.",INVALID_DRIVE_LETTER:"Use a single letter from A to Z.",
FOLDER_SCOPE_MISMATCH:"Profile and access requirement must belong to the company and the same person.",FOLDER_TARGET_IMMUTABLE:"Create another configuration to change the profile or requirement.",
FOLDER_UNAVAILABLE:"Profile, resource and requirement must be current. The path must match the resource's current reference.",
FOLDER_MAPPING_EXISTS:"An open folder configuration already exists for this profile.",DRIVE_LETTER_IN_USE:"Another folder in this profile already reserves this letter.",
INVALID_FOLDER_TRANSITION:"Invalid status change. A configured folder must first be marked for removal.",
FOLDER_MAPPING_NOT_FOUND:"The configuration is unavailable.",COMPANY_NOT_FOUND:"The company is unavailable.",INTERNAL_ERROR:"Unable to complete the operation. Try again.",
UNAUTHENTICATED:"Your session expired.",FORBIDDEN:"Reload the page to authorize the operation."
},
};

View File

@ -0,0 +1,106 @@
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";
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>
<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("");}
}}} />}
</>;
}
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>;
}

View File

@ -0,0 +1,76 @@
import {Button} from "@repo/shadcn-ui/components/button";
import {Card,CardContent} from "@repo/shadcn-ui/components/card";
import {FormDialog} from "@repo/shadcn-ui/components/form-dialog";
import {Input} from "@repo/shadcn-ui/components/input";
import {useQuery} from "@tanstack/react-query";
import {useState} from "react";
import {useTranslation} from "react-i18next";
import type {CompanyDTO,AccessItemDTO} from "../../common";
import {accessApi,accessKeys} from "../access/access-api";
import {profileApi,profileKeys} from "../profiles/work-profile-api";
import {folderApi,folderKeys} from "./folder-api";
import {FolderFeedback} from "./folder-feedback";
import {FolderMappingForm} from "./folder-mapping-form";
export function FolderMappingsPage({company,csrfToken,initialProfileId}:{
company:CompanyDTO;csrfToken:string;initialProfileId?:string;
}){
const {t,i18n}=useTranslation("companies");
const [editor,setEditor]=useState<string>(),[view,setView]=useState<string>(),[search,setSearch]=useState("");
const query=useQuery({queryKey:folderKeys.company(company.id),queryFn:()=>folderApi.get(company.id)});
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 items=access.data?.items ?? [],mappings=query.data?.mappings ?? [];
const profile=(id:string)=>profiles.data?.profiles.find(item=>item.id===id)?.values.name ?? id;
const folder=(id:string)=>folderDetails(items,id);
const filtered=mappings.filter(item=>(!initialProfileId || item.values.profileId===initialProfileId) &&
[item.values.path,profile(item.values.profileId),folder(item.values.needId).name].some(text=>text.toLowerCase().includes(search.trim().toLowerCase())));
const edited=mappings.find(item=>item.id===editor),viewed=mappings.find(item=>item.id===view);
const ready=[query.data,access.data,profiles.data].every(Boolean);
return <div className="space-y-5">
<header className="flex flex-wrap justify-between items-center gap-4"><h2 className="text-xl font-semibold">{t("folders.title")}</h2>
<Button disabled={!ready} onClick={()=>setEditor("new")}>{t("folders.new")}</Button></header>
<Input aria-label={t("folders.search")} placeholder={t("folders.search")} value={search} onChange={event=>setSearch(event.target.value)} />
{(query.isPending || access.isPending || profiles.isPending) && <p role="status">{t("loading")}</p>}
<FolderFeedback error={query.error ?? access.error ?? profiles.error} />
{(query.isError || access.isError || profiles.isError) && <Button variant="outline" onClick={()=>{
Promise.all([query.refetch(),access.refetch(),profiles.refetch()]).catch(()=>undefined);
}}>{t("retry")}</Button>}
{ready && <Card><CardContent className="divide-y pt-3">
{!filtered.length && <p className="py-4 text-muted-foreground">{t(mappings.length ? "noResults":"folders.empty")}</p>}
{filtered.map(item=><div key={item.id} className="flex flex-wrap justify-between gap-4 py-4">
<div className="min-w-0 break-words">
<h3 className="font-medium">{folder(item.values.needId).name} · {profile(item.values.profileId)}</h3>
<p className="font-mono text-sm">{item.values.driveLetter ? item.values.driveLetter+":":"—"} {item.values.path}</p>
<p className="text-sm text-muted-foreground">{t("folders.permissions")}: {folder(item.values.needId).permissions}</p>
<p className="text-sm mt-1">{t("mail."+item.values.state)}</p>
{!["removed","never_configured"].includes(item.values.state) && <p className="text-sm mt-2">
{t(item.permissionsCovered ? "folders.covered":"folders.uncovered")}</p>}
{item.needsReview && <p className="text-sm mt-1 text-amber-700">{t("folders.review")}</p>}
</div>
<div className="flex gap-2 items-start"><Button size="sm" variant="outline" onClick={()=>setView(item.id)}>{t("mail.history")}</Button>
{!["removed","never_configured"].includes(item.values.state) && <Button size="sm" variant="outline" onClick={()=>setEditor(item.id)}>{t("edit")}</Button>}</div>
</div>)}
</CardContent></Card>}
{editor && <FormDialog open wide formActions title={t(edited ? "folders.edit":"folders.new")} cancelLabel={t("cancel")}
onOpenChange={open=>{if(!open){setEditor(undefined);}}}>
<FolderMappingForm company={company} csrfToken={csrfToken} mapping={edited} initialProfileId={initialProfileId ?? ""} onSaved={()=>setEditor(undefined)} />
</FormDialog>}
{viewed && <FormDialog open wide title={t("mail.history")} cancelLabel={t("close")} onOpenChange={open=>{if(!open){setView(undefined);}}}>
<ol className="space-y-3">{[...viewed.history].reverse().map(revision=><li key={revision.id} className="rounded-md border p-3 text-sm">
<p className="font-medium">{new Intl.DateTimeFormat(i18n.language,{dateStyle:"medium",timeStyle:"short"}).format(new Date(revision.recordedAt))} · {revision.actorName}</p>
<p className="font-mono">{revision.values.driveLetter ? revision.values.driveLetter+":":"—"} {revision.values.path}</p>
<p>{t("mail."+revision.values.state)}</p>
{revision.values.notes && <p className="whitespace-pre-wrap mt-2">{revision.values.notes}</p>}
</li>)}</ol>
</FormDialog>}
</div>;
}
export function folderDetails(items:readonly AccessItemDTO[],id:string){
const need=items.find(item=>item.id===id)?.values;
if(need?.kind!=="need"){return {name:id,permissions:""};}
const resource=items.find(item=>item.id===need.resourceId)?.values;
return {name:resource?.kind==="resource" ? resource.name:need.resourceId,
permissions:need.permissionIds.map(permission=>{const item=items.find(item=>item.id===permission)?.values;
return item?.kind==="permission" ? item.name:permission;}).join(" · ")};
}

View File

@ -1,3 +1,4 @@
import {foldersSpanish,foldersEnglish} from "./folders/folder-i18n";
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
@ -5,6 +6,7 @@ import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
export const companiesSpanish = {
mail: mailSpanish,
mailbox: mailboxSpanish,
folders:foldersSpanish,
profiles: profilesSpanish,
companySpaces: "Espacios de compañías",
globalCatalogs: "Catálogos globales",
@ -122,6 +124,7 @@ export const companiesSpanish = {
export const companiesEnglish = {
mail: mailEnglish,
mailbox: mailboxEnglish,
folders:foldersEnglish,
profiles: profilesEnglish,
companySpaces: "Company spaces",
globalCatalogs: "Global catalogs",

View File

@ -1,3 +1,4 @@
import {FolderMappingsPage} from "../folders/folder-mappings-page";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
@ -24,6 +25,7 @@ export function WorkProfilesPage({
csrfToken: string;
}) {
const { t, i18n } = useTranslation("companies");
const [folderProfile,setFolderProfile]=useState<string>();
const [mailProfile, setMailProfile] = useState<string>();
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
@ -61,6 +63,8 @@ export function WorkProfilesPage({
)
);
const ready = [query.data, inventory.data, access.data].every(Boolean);
if(folderProfile){return <div className="space-y-4"><Button variant="outline" onClick={()=>setFolderProfile(undefined)}>{t("profiles.title")}</Button>
<FolderMappingsPage company={company} csrfToken={csrfToken} initialProfileId={folderProfile} /></div>;}
if (mailProfile) {
return (
<div className="space-y-4">
@ -136,7 +140,8 @@ export function WorkProfilesPage({
<p className="mt-2 text-sm text-amber-700">{t("profiles.review")}</p>
)}
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button onClick={()=>setFolderProfile(item.id)} size="sm" variant="outline">{t("folders.title")}</Button>
<Button onClick={() => setMailProfile(item.id)} size="sm" variant="outline">
{t("mail.title")}
</Button>