feat(companies): vincular identidades locales a activos y añadir cuenta de acceso a FolderMapping

- Asociar entornos locales con activos y conservar su trazabilidad.
- Registrar la cuenta de acceso del mapeo con historial de cambios.
- Calcular cobertura documental específica sin alterar la cobertura general.
- Añadir migraciones, validaciones, endpoints protegidos, pruebas y documentación.
This commit is contained in:
David Arranz 2026-10-21 11:25:23 +02:00
parent 6b48bfacd1
commit fe6ec361b7
96 changed files with 7946 additions and 272 deletions

View File

@ -12,7 +12,11 @@
"test:directory-domains": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/directory-domains.integration.test.ts",
"test:directory-memberships": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/asset-directory-memberships.integration.test.ts",
"test:relationships": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/typed-relationships.integration.test.ts",
"test:windows-domains-ui": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/windows-domains-ui.integration.test.ts"
"test:windows-domains-ui": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/windows-domains-ui.integration.test.ts",
"test:network-shares": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/network-shares.integration.test.ts",
"test:smb-access": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/smb-access.integration.test.ts",
"test:network-share-publications": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/network-share-publications.integration.test.ts",
"test:local-identities-folder-mapping": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/local-identities-folder-mapping.integration.test.ts"
},
"dependencies": {
"@metis/assets": "workspace:*",

View File

@ -31,12 +31,18 @@ export async function prepareMembershipFixture(
const resources = await createResourceModule(
database,
identity.authenticate,
companies.directoryContexts
companies.directoryContexts,
companies.resourceAccess
);
const assets = await createAssetModule(
database,
identity.authenticate,
resources.services.directoryDomains
resources.services.directoryDomains,
resources.services.networkShares
);
const localIdentities = await companies.initializeLocalIdentities(
assets.services.localIdentities
);
await database.query(
@ -70,9 +76,10 @@ export async function prepareMembershipFixture(
identity.register(app);
registerDirectoryRelationshipRoutes(app, relationships, identity.authenticate);
assets.register(app);
localIdentities.register(app);
resources.register(app);
const resourceUseCases = new ResourceUseCases(
new SequelizeResourceRepository(database, companies.directoryContexts)
new SequelizeResourceRepository(database, companies.directoryContexts, companies.resourceAccess)
);
const assetUseCases = new AssetUseCases(new SequelizeAssetRepository(database));
const membershipUseCases = new DirectoryMembershipUseCases(
@ -139,6 +146,11 @@ export async function prepareMembershipFixture(
return {
app,
localIdentities: localIdentities.useCases,
inventory: assets.services.inventory,
authenticate: identity.authenticate,
resourceAccess: companies.resourceAccess,
networkShareReader: resources.services.networkShares,
domain,
asset,
resourceUseCases,
@ -156,6 +168,29 @@ export async function cleanupMembershipFixture(
actorId: string
) {
for (const id of companyIds) {
await database.query("DELETE FROM access_local_identity_environments WHERE companyId=:id", {
replacements: { id },
});
await database.query(
"UPDATE asset_network_share_publications SET predecessorId=NULL WHERE companyId=:id",
{ replacements: { id } }
);
await database.query("DELETE FROM asset_network_share_publications WHERE companyId=:id", {
replacements: { id },
});
await database.query("DELETE FROM resource_network_shares WHERE companyId=:id", {
replacements: { id },
});
for (const kind of ["resource", "resource_type"]) {
await database.query(
`DELETE v FROM access_${kind}_revision_values v JOIN access_revisions r ON r.id=v.ownerId JOIN access_items i ON i.id=r.itemId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:id`,
{ replacements: { id } }
);
await database.query(
`DELETE v FROM access_${kind}_values v JOIN access_items i ON i.id=v.ownerId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:id`,
{ replacements: { id } }
);
}
await database.query(
"DELETE v FROM asset_directory_membership_revisions v JOIN asset_directory_memberships m ON m.id=v.membershipId WHERE m.companyId=:id",
{ replacements: { id } }

View File

@ -43,7 +43,8 @@ test("MariaDB: dominios, contexto atómico, concurrencia, integridad y ciclo de
const resources = await createResourceModule(
database,
(_request, _response, next) => next(),
companies.directoryContexts
companies.directoryContexts,
companies.resourceAccess
);
fixtureSchemaReady = true;

View File

@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import { DirectoryRelationshipHttpQuerySchema } from "@metis/assets/common";
import {
DirectoryRelationshipHttpQuerySchema,
NETWORK_SHARE_PUBLICATION_TYPE_ID,
} from "@metis/assets/common";
import type { RelationshipContext } from "@metis/relationship-contracts";
import { DirectoryDomainOptionsQuerySchema } from "@metis/resources/common";
import { Result } from "@repo/rdx-utils";
@ -10,6 +13,10 @@ import { z } from "zod/v4";
import type { createRelationshipRuntime } from "./relationship-runtime";
const SupportedRelationshipQuerySchema = DirectoryRelationshipHttpQuerySchema.extend({
typeId: z.enum(["assets:directory-membership", NETWORK_SHARE_PUBLICATION_TYPE_ID]),
});
type Runtime = ReturnType<typeof createRelationshipRuntime>;
function fail(response: Response, code: string) {
@ -44,11 +51,13 @@ async function contextFor(request: Request, response: Response, runtime: Runtime
async function readPage(
runtime: Runtime,
input: z.infer<typeof DirectoryRelationshipHttpQuerySchema>,
input: z.infer<typeof SupportedRelationshipQuerySchema>,
context: RelationshipContext
) {
const fromAsset = input.anchorType === "assets:asset";
const page = await runtime.memberships.list(
const publication = input.typeId === NETWORK_SHARE_PUBLICATION_TYPE_ID;
const service = publication ? runtime.publications : runtime.memberships;
const page = await service.list(
{
typeId: input.typeId,
anchor: {
@ -69,7 +78,11 @@ async function readPage(
return page;
}
const references = page.data.items.map((item) => (fromAsset ? item.target : item.source));
const resolver = fromAsset ? runtime.domainSummaries : runtime.assetSummaries;
const resolver = fromAsset
? publication
? runtime.shareSummaries
: runtime.domainSummaries
: runtime.assetSummaries;
const resolved = await resolver.resolveMany(references, context);
return resolved.isSuccess ? Result.ok({ page: page.data, summaries: resolved.data }) : resolved;
@ -83,32 +96,39 @@ export function registerDirectoryRelationshipRoutes(
) {
const router = Router();
router.get("/:companyId/relationships", authenticate, async (request, response, next) => {
try {
const parsed = DirectoryRelationshipHttpQuerySchema.safeParse(request.query);
app.get(
[
"/api/asset-directory-memberships/:companyId/relationships",
"/api/asset-network-share-publications/:companyId/relationships",
],
authenticate,
async (request, response, next) => {
try {
const parsed = SupportedRelationshipQuerySchema.safeParse(request.query);
if (!parsed.success) {
fail(response, "INVALID_QUERY");
if (!parsed.success) {
fail(response, "INVALID_QUERY");
return;
return;
}
const context = await contextFor(request, response, runtime);
if (!context) {
return;
}
const result = await readPage(runtime, parsed.data, context);
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(result.data);
} catch (error) {
next(error);
}
const context = await contextFor(request, response, runtime);
if (!context) {
return;
}
const result = await readPage(runtime, parsed.data, context);
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(result.data);
} catch (error) {
next(error);
}
});
);
router.get("/:companyId/domains", authenticate, async (request, response, next) => {
try {
const query = DirectoryDomainOptionsQuerySchema.safeParse(request.query);

View File

@ -30,12 +30,17 @@ export async function createIdentityRuntime(
const resources = await createResourceModule(
database,
identity.authenticate,
companies.directoryContexts
companies.directoryContexts,
companies.resourceAccess
);
const assets = await createAssetModule(
database,
identity.authenticate,
resources.services.directoryDomains
resources.services.directoryDomains,
resources.services.networkShares
);
const localIdentities = await companies.initializeLocalIdentities(
assets.services.localIdentities
);
const relationships = createRelationshipRuntime(identity, companies, assets, resources);
const mail = await companies.initializeMail(assets.services.inventory);
@ -69,6 +74,7 @@ export async function createIdentityRuntime(
register: (app: Parameters<typeof identity.register>[0]) => {
identity.register(app);
companies.register(app);
localIdentities.register(app);
registerDirectoryRelationshipRoutes(app, relationships, identity.authenticate);
assets.register(app);
resources.register(app);

View File

@ -1,4 +1,7 @@
import { DIRECTORY_MEMBERSHIP_DEFINITION } from "@metis/assets/common";
import {
DIRECTORY_MEMBERSHIP_DEFINITION,
NETWORK_SHARE_PUBLICATION_DEFINITION,
} from "@metis/assets/common";
import type { createAssetModule } from "@metis/assets/module";
import type { createCompanyModule } from "@metis/companies/module";
import type { createIdentityModule } from "@metis/identity/module";
@ -16,8 +19,8 @@ export function createRelationshipRuntime(
) {
const registered = createRelationshipRegistry(
["assets", "resources"],
[DIRECTORY_MEMBERSHIP_DEFINITION],
["directory:domain"]
[DIRECTORY_MEMBERSHIP_DEFINITION, NETWORK_SHARE_PUBLICATION_DEFINITION],
["directory:domain", "network:smb-share"]
);
if (!registered.isSuccess) {
@ -27,6 +30,7 @@ export function createRelationshipRuntime(
(session) => identity.services.sessions.isAuthenticated(session),
(companyId) => companies.existence.exists(companyId)
);
const shareSummaries = resources.services.networkShareSummaries(access);
const domainSummaries = resources.services.directorySummaries(access);
const assetReads = assets.services.relationshipReads(registered.data, access, domainSummaries);
@ -34,6 +38,8 @@ export function createRelationshipRuntime(
registry: registered.data,
contexts: access,
memberships: assetReads.memberships,
publications: assets.services.publicationReads(registered.data, access, shareSummaries),
shareSummaries,
eligibility: assetReads.eligibility,
domainOptions: resources.services.directoryOptions(access),
assetSummaries: assetReads.summaries,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,224 @@
import assert from "node:assert/strict";
import type { Sequelize } from "sequelize";
import { AccessUseCases } from "../../../modules/companies/src/api/application/access.use-cases";
import { FolderMappingUseCases } from "../../../modules/companies/src/api/application/folder-mapping.use-cases";
import { buildFolderResponse } from "../../../modules/companies/src/api/application/folder-mapping-response.builder";
import { WorkProfileUseCases } from "../../../modules/companies/src/api/application/work-profile.use-cases";
import {
AccessPersistenceMapper,
SequelizeAccessRepository,
} from "../../../modules/companies/src/api/infrastructure/persistence/access-persistence";
import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper";
import { migrateFolderMappings } from "../../../modules/companies/src/api/infrastructure/persistence/folder-mapping-migration";
import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository";
import { SequelizeFolderMappingRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-folder-mapping.repository";
import { SequelizeWorkProfileRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-work-profile.repository";
import { migrateWorkProfiles } from "../../../modules/companies/src/api/infrastructure/persistence/work-profile-migration";
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
import type { prepareMembershipFixture } from "./asset-directory-memberships.fixture";
/** Comprueba un mapeo persistido real; todos los datos pertenecen a la compañía de fixture. */
export async function verifyFolderCompatibility(
database: Sequelize,
fixture: Awaited<ReturnType<typeof prepareMembershipFixture>>,
company: string,
actor: string
) {
await migrateWorkProfiles(database);
await migrateFolderMappings(database);
const personId = UniqueID.generateNewID().toPrimitive();
const asset = await fixture.asset(company, "desktop");
const inventory = { list: async () => [{ id: asset.id, notRetired: true, isProfileHost: true }] };
const access = new AccessUseCases(
new SequelizeAccessRepository(
database,
new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()),
new AccessPersistenceMapper()
)
);
const profiles = new WorkProfileUseCases(new SequelizeWorkProfileRepository(database, inventory));
const folders = new FolderMappingUseCases(
new SequelizeFolderMappingRepository(database, inventory)
);
const loaded = await fixture.resourceUseCases.get(company);
assert.ok(loaded.isSuccess);
const resource = loaded.data.resources.find(
(item) => item.values.toPrimitive().integration?.kind === "access"
);
assert.ok(resource);
const values = resource.values.toPrimitive();
assert.ok(values.integration?.kind === "access");
const { referenceFieldId, resourceTypeId } = values.integration;
const originalPath = values.characteristics[referenceFieldId];
assert.equal(typeof originalPath, "string");
assert.ok(typeof originalPath === "string");
await database.query(
"INSERT INTO company_contacts (id,companyId,name,isContact,active) VALUES (:personId,:company,'SMB person fixture',0,1)",
{ replacements: { personId, company } }
);
try {
const permission = await access.save(
company,
undefined,
{
kind: "permission",
name: "SMB read fixture",
active: true,
description: null,
resourceTypeId,
},
actor
);
assert.ok(permission.isSuccess);
const permissionItem = permission.data.workspace.items.find(
(item) => item.values.data.kind === "permission"
);
assert.ok(permissionItem);
const need = await access.save(
company,
undefined,
{
kind: "need",
personId,
resourceId: resource.id.toPrimitive(),
permissionIds: [permissionItem.id.toPrimitive()],
startDate: "2020-01-01",
endDate: null,
notes: null,
},
actor
);
assert.ok(need.isSuccess);
const needItem = need.data.workspace.items.find((item) => item.values.data.kind === "need");
assert.ok(needItem);
const profile = await profiles.save(
company,
undefined,
{
name: "SMB profile fixture",
personId,
assetId: asset.id.toPrimitive(),
accountId: null,
active: true,
notes: null,
},
actor
);
assert.ok(profile.isSuccess);
const mapping = await folders.save(
company,
undefined,
{
profileId: profile.data.profile.id.toPrimitive(),
needId: needItem.id.toPrimitive(),
path: originalPath,
driveLetter: "F:",
state: "configured",
notes: null,
},
actor
);
assert.ok(mapping.isSuccess);
const before = buildFolderResponse(mapping.data.mapping, mapping.data.context);
assert.equal(before.needsReview, false);
assert.equal(before.permissionsCovered, false);
const changed = await fixture.resourceUseCases.save(
company,
resource.id.toPrimitive(),
{
...values,
characteristics: {
...values.characteristics,
[referenceFieldId]: String.raw`\\NAS\NuevaDireccion`,
},
},
actor
);
assert.ok(changed.isSuccess);
const after = await folders.get(company);
assert.ok(after.isSuccess);
const persisted = after.data.mappings.find((item) => item.id.equals(mapping.data.mapping.id));
assert.ok(persisted);
const response = buildFolderResponse(persisted, after.data);
assert.deepEqual(response.values, before.values);
assert.deepEqual(response.history, before.history);
assert.equal(response.values.driveLetter, "F");
assert.equal(response.needsReview, true);
assert.equal(response.permissionsCovered, false);
const blocked = await fixture.resourceUseCases.remove(company, resource.id.toPrimitive());
assert.ok(!blocked.isSuccess);
assert.equal(blocked.error.code, "RESOURCE_IN_USE");
} finally {
for (const [table, column, parent] of [
["folder_mapping_revisions", "mappingId", "folder_mappings"],
["work_profile_revisions", "profileId", "work_profiles"],
]) {
await database.query(
`DELETE FROM ${table} WHERE ${column} IN (SELECT id FROM ${parent} WHERE companyId=:company)`,
{ replacements: { company } }
);
}
for (const table of ["folder_mappings", "work_profiles"]) {
await database.query(`DELETE FROM ${table} WHERE companyId=:company`, {
replacements: { company },
});
}
for (const kind of ["need", "permission"]) {
if (kind === "need") {
await database.query(
"DELETE p FROM access_need_permissions p JOIN access_items i ON i.id=p.ownerId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company",
{ replacements: { company } }
);
await database.query(
"DELETE p FROM access_need_revision_permissions p JOIN access_revisions r ON r.id=p.ownerId JOIN access_items i ON i.id=r.itemId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company",
{ replacements: { company } }
);
}
await database.query(
`DELETE v FROM access_${kind}_values v JOIN access_items i ON i.id=v.ownerId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company`,
{ replacements: { company } }
);
await database.query(
`DELETE v FROM access_${kind}_revision_values v JOIN access_revisions r ON r.id=v.ownerId JOIN access_items i ON i.id=r.itemId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company`,
{ replacements: { company } }
);
}
await database.query("DELETE FROM access_person_references WHERE personId=:personId", {
replacements: { personId },
});
for (const table of ["access_references", "access_revisions"]) {
const column = "itemId";
await database.query(
`DELETE v FROM ${table} v JOIN access_items i ON i.id=v.${column} JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company AND i.kind IN ('need','permission')`,
{ replacements: { company } }
);
}
await database.query(
"DELETE i FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company AND i.kind IN ('need','permission')",
{ replacements: { company } }
);
await database.query("DELETE FROM company_contacts WHERE id=:personId AND companyId=:company", {
replacements: { personId, company },
});
}
}

View File

@ -0,0 +1,884 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { test } from "node:test";
import type { RelationshipContext, RelationshipQuery } from "@metis/relationship-contracts";
import { RelationshipPageSchema } from "@metis/relationship-contracts";
import { z } from "zod/v4";
import { NetworkSharePublicationUseCases } from "../../../modules/assets/src/api/application/network-share-publication.use-cases";
import { migrateNetworkSharePublications } from "../../../modules/assets/src/api/infrastructure/persistence/network-share-publication-migration";
import { SequelizeNetworkSharePublicationRepository } from "../../../modules/assets/src/api/infrastructure/persistence/sequelize-network-share-publication.repository";
import {
NETWORK_SHARE_PUBLICATION_TYPE_ID,
NetworkSharePublicationPageSchema,
NetworkSharePublicationSchema,
} from "../../../modules/assets/src/common/network-share-publications";
import { AccessUseCases } from "../../../modules/companies/src/api/application/access.use-cases";
import { buildAccessResponse } from "../../../modules/companies/src/api/application/access-response.builder";
import {
AccessPersistenceMapper,
SequelizeAccessRepository,
} from "../../../modules/companies/src/api/infrastructure/persistence/access-persistence";
import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper";
import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository";
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
import {
cleanupMembershipFixture,
prepareMembershipFixture,
} from "./asset-directory-memberships.fixture";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
test("MariaDB: publicaciones SMB, historial, proveedor y HTTP autenticado", async (t) => {
const env = readDatabaseEnvironment(process.env);
assert.equal(env.NODE_ENV, "test");
assert.equal(env.DB_NAME, "metis_desk_test");
const db = createDatabase(env);
const sql: string[] = [];
db.addHook("afterQuery", "publication-sql", (_options, query) => {
const parsed = z.object({ sql: z.string() }).safeParse(query);
if (parsed.success) {
sql.push(parsed.data.sql);
}
});
const actor = UniqueID.generateNewID().toPrimitive();
const company = UniqueID.generateNewID().toPrimitive();
const otherCompany = UniqueID.generateNewID().toPrimitive();
let ready = false;
try {
const f = await prepareMembershipFixture(db, "http://localhost:5173", actor, [
company,
otherCompany,
]);
ready = true;
await migrateNetworkSharePublications(db);
const service = new NetworkSharePublicationUseCases(
new SequelizeNetworkSharePublicationRepository(db, f.networkShareReader)
);
const initial = await f.resourceUseCases.get(company);
assert.ok(initial.isSuccess);
const template = initial.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:shared_folder"
);
const unc = initial.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:unc"
);
const ordinaryTemplate = initial.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:application"
);
assert.ok(template && unc && ordinaryTemplate);
const share = async (name: string, scope = company) => {
const result = await f.resourceUseCases.save(
scope,
undefined,
{
name,
templateId: template.id.toPrimitive(),
active: true,
notes: "",
characteristics: { [unc.id.toPrimitive()]: `\\\\alias\\${name}` },
},
actor
);
assert.ok(result.isSuccess && result.data.networkShare);
return result.data.resource;
};
const resources = await Promise.all([
share("Finanzas"),
share("Documentos"),
share("Proyectos"),
share("Desconocido"),
]);
const [first, second, third, unknown] = resources;
assert.ok(first && second && third && unknown);
const assets: Awaited<ReturnType<typeof f.asset>>[] = [];
for (const kind of ["server", "nas", "desktop", "laptop", "server"]) {
assets.push(await f.asset(company, kind));
}
const [server, nas, desktop, laptop, linux] = assets;
assert.ok(server && nas && desktop && laptop && linux);
const foreign = await f.asset(otherCompany);
const foreignShare = await share("OtraCompania", otherCompany);
const ordinary = await f.resourceUseCases.save(
company,
undefined,
{
name: "Ordinario",
templateId: ordinaryTemplate.id.toPrimitive(),
active: true,
notes: "",
characteristics: {},
},
actor
);
assert.ok(ordinary.isSuccess);
const execute = (input: unknown) => service.execute(company, input, actor);
const published = await execute({
action: "publish",
assetId: server.id.toPrimitive(),
resourceId: first.id.toPrimitive(),
});
assert.ok(published.isSuccess);
let current = published.data;
await t.test(
"registro y cardinalidad; publicador desconocido y distintos tipos sin AD",
async () => {
assert.ok(
f.relationships.registry.definitions.some(
(item) => item.typeId === NETWORK_SHARE_PUBLICATION_TYPE_ID
)
);
assert.ok(
f.relationships.registry.definitions.some(
(item) => item.typeId === "assets:directory-membership"
)
);
const empty = await service.list(company, { resourceId: unknown.id.toPrimitive() }, actor);
assert.ok(empty.isSuccess);
assert.deepEqual(empty.data.publications, []);
for (const [index, asset] of assets.entries()) {
const resource = await share(`Tipo${index}`);
const result = await execute({
action: "publish",
assetId: asset.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
});
assert.ok(result.isSuccess);
}
for (const resource of [second, third]) {
assert.ok(
(
await execute({
action: "publish",
assetId: server.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
})
).isSuccess
);
}
const repeat = await execute({
action: "publish",
assetId: server.id.toPrimitive(),
resourceId: first.id.toPrimitive(),
});
assert.ok(repeat.isSuccess);
assert.equal(repeat.data.id.toPrimitive(), current.id.toPrimitive());
assert.equal(
(
await execute({
action: "publish",
assetId: nas.id.toPrimitive(),
resourceId: first.id.toPrimitive(),
})
).isSuccess,
false
);
assert.equal(
(
await execute({
action: "publish",
assetId: foreign.id.toPrimitive(),
resourceId: first.id.toPrimitive(),
})
).isSuccess,
false
);
assert.equal(
(
await execute({
action: "publish",
assetId: nas.id.toPrimitive(),
resourceId: foreignShare.id.toPrimitive(),
})
).isSuccess,
false
);
assert.equal(
(
await execute({
action: "publish",
assetId: nas.id.toPrimitive(),
resourceId: ordinary.data.resource.id.toPrimitive(),
})
).isSuccess,
false
);
}
);
await t.test(
"traslado atómico, replay estable y finalización identificada sin tocar accesos",
async () => {
const access = new AccessUseCases(
new SequelizeAccessRepository(
db,
new SequelizeCompanyRepository(db, new CompanyPersistenceMapper()),
new AccessPersistenceMapper()
)
);
const accessBefore = await access.get(company);
assert.ok(accessBefore.isSuccess);
const before = await f.resourceUseCases.get(company);
assert.ok(before.isSuccess);
const history = before.data.resources
.find((item) => item.id.equals(first.id))
?.history.map((item) => item.id.toPrimitive());
const transfer = {
action: "transfer",
publicationId: current.id.toPrimitive(),
assetId: nas.id.toPrimitive(),
};
const moved = await execute(transfer);
assert.ok(moved.isSuccess);
const oldId = current.id.toPrimitive();
current = moved.data;
const repeated = await execute(transfer);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data.id.toPrimitive(), current.id.toPrimitive());
const oldEnded = await execute({ action: "end", publicationId: oldId });
assert.ok(oldEnded.isSuccess);
assert.equal(oldEnded.data.id.toPrimitive(), oldId);
const list = await service.list(
company,
{ resourceId: first.id.toPrimitive(), includeEnded: "true" },
actor
);
assert.ok(list.isSuccess);
assert.equal(list.data.publications.length, 2);
assert.equal(list.data.publications.filter((item) => item.endedAt === null).length, 1);
const ended = await execute({ action: "end", publicationId: current.id.toPrimitive() });
assert.ok(ended.isSuccess);
const endedAgain = await execute({
action: "end",
publicationId: current.id.toPrimitive(),
});
assert.ok(endedAgain.isSuccess);
assert.deepEqual(endedAgain.data.toPrimitive(), ended.data.toPrimitive());
const replay = await execute(transfer);
assert.ok(replay.isSuccess);
assert.equal(replay.data.id.toPrimitive(), current.id.toPrimitive());
const republished = await execute({
action: "publish",
assetId: desktop.id.toPrimitive(),
resourceId: first.id.toPrimitive(),
});
assert.ok(republished.isSuccess);
assert.notEqual(republished.data.id.toPrimitive(), current.id.toPrimitive());
current = republished.data;
const accessAfter = await access.get(company);
assert.ok(accessAfter.isSuccess);
assert.deepEqual(
buildAccessResponse(accessAfter.data),
buildAccessResponse(accessBefore.data)
);
const after = await f.resourceUseCases.get(company);
assert.ok(after.isSuccess);
assert.deepEqual(
after.data.resources
.find((item) => item.id.equals(first.id))
?.history.map((item) => item.id.toPrimitive()),
history
);
}
);
await t.test(
"concurrencia: altas iguales, publicadores distintos, traslados y finalización",
async () => {
const resource = await share("Carrera");
const publish = {
action: "publish",
assetId: server.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
};
const same = await Promise.all([execute(publish), execute(publish)]);
assert.ok(same[0]?.isSuccess && same[1]?.isSuccess);
assert.equal(same[0].data.id.toPrimitive(), same[1].data.id.toPrimitive());
const transfers = await Promise.all([
execute({
action: "transfer",
publicationId: same[0].data.id.toPrimitive(),
assetId: nas.id.toPrimitive(),
}),
execute({
action: "transfer",
publicationId: same[0].data.id.toPrimitive(),
assetId: desktop.id.toPrimitive(),
}),
]);
assert.equal(transfers.filter((result) => result.isSuccess).length, 1);
const winner = transfers.find((result) => result.isSuccess);
assert.ok(winner?.isSuccess);
const raced = await Promise.all([
execute({ action: "end", publicationId: winner.data.id.toPrimitive() }),
execute({
action: "transfer",
publicationId: winner.data.id.toPrimitive(),
assetId: laptop.id.toPrimitive(),
}),
]);
assert.ok(raced.some((result) => result.isSuccess));
const two = await share("DosAltas");
const different = await Promise.all([
execute({
action: "publish",
assetId: server.id.toPrimitive(),
resourceId: two.id.toPrimitive(),
}),
execute({
action: "publish",
assetId: nas.id.toPrimitive(),
resourceId: two.id.toPrimitive(),
}),
]);
assert.equal(different.filter((result) => result.isSuccess).length, 1);
}
);
await t.test(
"restricciones SQL y protección de borrado también después de finalizar",
async () => {
const row = current.toPrimitive();
const insert =
"INSERT INTO asset_network_share_publications (id,companyId,assetId,resourceId,startedAt,startedBy,activeResourceId) VALUES (:id,:companyId,:assetId,:resourceId,:startedAt,:startedBy,:resourceId)";
for (const patch of [
{},
{ resourceId: foreignShare.id.toPrimitive() },
{ assetId: foreign.id.toPrimitive() },
{ resourceId: ordinary.data.resource.id.toPrimitive() },
]) {
await assert.rejects(
db.query(insert, {
replacements: { ...row, ...patch, id: UniqueID.generateNewID().toPrimitive() },
})
);
}
assert.ok(
(await execute({ action: "end", publicationId: current.id.toPrimitive() })).isSuccess
);
const resourceDelete = await f.resourceUseCases.remove(company, first.id.toPrimitive());
const assetDelete = await f.assetUseCases.remove(company, desktop.id.toPrimitive());
assert.ok(!(resourceDelete.isSuccess || assetDelete.isSuccess));
assert.equal(resourceDelete.error.code, "RESOURCE_IN_USE");
assert.equal(assetDelete.error.code, "ASSET_IN_USE");
}
);
await t.test("fallo al insertar sucesor revierte el cierre anterior", async () => {
const resource = await share("Rollback");
const published = await execute({
action: "publish",
assetId: server.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
});
assert.ok(published.isSuccess);
const trigger = `publication_fixture_${resource.id.toPrimitive().replaceAll("-", "")}`;
await db.query(
`CREATE TRIGGER ${trigger} BEFORE INSERT ON asset_network_share_publications FOR EACH ROW BEGIN IF NEW.assetId='${linux.id.toPrimitive()}' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='publication fixture rollback'; END IF; END`
);
try {
await assert.rejects(
execute({
action: "transfer",
publicationId: published.data.id.toPrimitive(),
assetId: linux.id.toPrimitive(),
})
);
const list = await service.list(
company,
{ resourceId: resource.id.toPrimitive(), includeEnded: "true" },
actor
);
assert.ok(list.isSuccess);
assert.equal(list.data.publications.length, 1);
assert.equal(list.data.publications[0]?.endedAt, null);
} finally {
await db.query(`DROP TRIGGER ${trigger}`);
}
});
const contextResult = await f.relationships.contexts.createContext(
{ userId: actor, authVersion: 1 },
company,
"publication-fixture"
);
assert.ok(contextResult.isSuccess);
const context: RelationshipContext = contextResult.data;
const query: RelationshipQuery = {
typeId: NETWORK_SHARE_PUBLICATION_TYPE_ID,
anchor: {
providerId: "assets",
entityType: "assets:asset",
id: server.id.toPrimitive(),
companyId: company,
},
direction: "outgoing",
includeEnded: true,
pageSize: 1,
};
await t.test(
"proveedor: SQL keyset, orientación, historial y cursores ligados a consulta",
async () => {
const sqlStart = sql.length;
const firstPage = await f.relationships.publications.list(query, context);
assert.ok(firstPage.isSuccess);
const statements = sql.slice(sqlStart);
assert.equal(
statements.filter((statement) =>
statement.includes("FROM asset_network_share_publications p")
).length,
1
);
assert.ok(statements.some((statement) => /ORDER BY p.id ASC LIMIT 2/.test(statement)));
assert.ok(
!statements.some((statement) => /asset_revisions|resource_revisions/.test(statement))
);
assert.ok(RelationshipPageSchema.safeParse(firstPage.data).success);
assert.ok(firstPage.data.nextCursor);
assert.equal(firstPage.data.items.length, 1);
assert.equal("roleCode" in (firstPage.data.items[0] ?? {}), false);
const secondPage = await f.relationships.publications.list(
{ ...query, cursor: firstPage.data.nextCursor },
context
);
assert.ok(secondPage.isSuccess);
assert.notDeepEqual(secondPage.data.items[0]?.origin, firstPage.data.items[0]?.origin);
assert.ok(
secondPage.data.items.every((item) => item.source.id === server.id.toPrimitive())
);
for (const changed of [
{ ...query, cursor: "invalid" },
{ ...query, cursor: firstPage.data.nextCursor, pageSize: 2 },
{ ...query, cursor: `${firstPage.data.nextCursor}x` },
]) {
assert.equal(
(await f.relationships.publications.list(changed, context)).isSuccess,
false
);
}
const inverse = await f.relationships.publications.list(
{
...query,
anchor: {
providerId: "resources",
entityType: "resources:resource",
id: first.id.toPrimitive(),
companyId: company,
},
direction: "incoming",
pageSize: 100,
},
context
);
assert.ok(inverse.isSuccess);
assert.equal(inverse.data.items.length, 3);
assert.ok(inverse.data.items.every((item) => item.target.id === first.id.toPrimitive()));
assert.ok(inverse.data.items.every((item) => item.stateCode === "ended"));
assert.equal(
(await f.relationships.publications.list(query, { ...context })).isSuccess,
false
);
const otherContext = await f.relationships.contexts.createContext(
{ userId: actor, authVersion: 1 },
otherCompany,
"other-company"
);
assert.ok(otherContext.isSuccess);
assert.equal(
(
await f.relationships.publications.list(
{
...query,
anchor: { ...query.anchor, id: foreign.id.toPrimitive(), companyId: otherCompany },
cursor: firstPage.data.nextCursor,
},
otherContext.data
)
).isSuccess,
false
);
const records = await service.list(company, { includeEnded: "true", pageSize: 1 }, actor);
assert.ok(records.isSuccess && records.data.nextCursor);
assert.ok(NetworkSharePublicationPageSchema.safeParse(records.data).success);
assert.equal(
(
await service.list(
company,
{ includeEnded: "false", pageSize: 1, cursor: records.data.nextCursor },
actor
)
).isSuccess,
false
);
}
);
await t.test(
"resolución SMB por lotes: no confunde dominios, recursos ordinarios o compañías",
async () => {
const domain = await f.domain(company, `publication-${company.slice(0, 8)}.local`);
const refs = [
first.id.toPrimitive(),
domain.id.toPrimitive(),
ordinary.data.resource.id.toPrimitive(),
UniqueID.generateNewID().toPrimitive(),
first.id.toPrimitive(),
].map((id) => ({
providerId: "resources",
entityType: "resources:resource" as const,
id,
companyId: company,
}));
const batchStart = sql.length;
const summaries = await f.relationships.shareSummaries.resolveMany(refs, context);
assert.ok(summaries.isSuccess);
assert.equal(
sql
.slice(batchStart)
.filter((statement) => statement.includes("JOIN resource_network_shares")).length,
1
);
assert.deepEqual(
summaries.data.map((item) => item.status),
["resolved", "unavailable", "unavailable", "unavailable", "resolved"]
);
const foreignSummary = await f.relationships.shareSummaries.resolveMany(
[
{
providerId: "resources",
entityType: "resources:resource",
id: foreignShare.id.toPrimitive(),
companyId: otherCompany,
},
],
context
);
assert.ok(foreignSummary.isSuccess);
assert.equal(foreignSummary.data[0]?.status, "unavailable");
for (const asset of [server, desktop]) {
assert.ok(
(
await f.membershipUseCases.execute(
company,
asset.id.toPrimitive(),
{ action: "join", domainResourceId: domain.id.toPrimitive(), role: "member" },
actor
)
).isSuccess
);
}
const directory = await f.relationships.memberships.list(
{
...query,
typeId: "assets:directory-membership",
anchor: {
providerId: "resources",
entityType: "resources:resource",
id: domain.id.toPrimitive(),
companyId: company,
},
direction: "incoming",
includeEnded: false,
},
context
);
assert.ok(directory.isSuccess && directory.data.nextCursor);
assert.equal(
(
await f.relationships.publications.list(
{ ...query, cursor: directory.data.nextCursor },
context
)
).isSuccess,
false
);
}
);
await t.test(
"estados y desactivación concurrente conservan publicaciones documentadas",
async () => {
const resource = await share("Inactivo");
assert.ok(
(await f.resourceUseCases.setActive(company, resource.id.toPrimitive(), false, actor))
.isSuccess
);
const rejected = await execute({
action: "publish",
assetId: laptop.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
});
assert.ok(!rejected.isSuccess);
assert.equal(rejected.error.code, "SMB_RESOURCE_INACTIVE");
const assetValues = laptop.values.toPrimitive();
const inactive = await f.assetUseCases.save(
company,
laptop.id.toPrimitive(),
{ ...assetValues, status: "retired" },
actor
);
assert.ok(inactive.isSuccess);
assert.equal(
(
await execute({
action: "publish",
assetId: laptop.id.toPrimitive(),
resourceId: unknown.id.toPrimitive(),
})
).isSuccess,
false
);
const assetRace = await f.asset(company, "desktop");
const assetShare = await share("ActivoCarrera");
const assetResults = await Promise.all([
execute({
action: "publish",
assetId: assetRace.id.toPrimitive(),
resourceId: assetShare.id.toPrimitive(),
}),
f.assetUseCases.save(
company,
assetRace.id.toPrimitive(),
{ ...assetRace.values.toPrimitive(), status: "retired" },
actor
),
]);
assert.ok(assetResults[1]?.isSuccess);
const assetPeriods = await service.list(
company,
{ resourceId: assetShare.id.toPrimitive(), includeEnded: "true" },
actor
);
assert.ok(assetPeriods.isSuccess);
assert.equal(assetPeriods.data.publications.length, assetResults[0]?.isSuccess ? 1 : 0);
assert.ok(assetPeriods.data.publications.every((item) => item.endedAt === null));
const raced = await share("EstadoCarrera");
await Promise.all([
execute({
action: "publish",
assetId: nas.id.toPrimitive(),
resourceId: raced.id.toPrimitive(),
}),
f.resourceUseCases.setActive(company, raced.id.toPrimitive(), false, actor),
]);
const recorded = await service.list(
company,
{ resourceId: raced.id.toPrimitive(), includeEnded: "true" },
actor
);
assert.ok(recorded.isSuccess);
assert.ok(recorded.data.publications.length <= 1);
assert.ok(recorded.data.publications.every((item) => item.endedAt === null));
}
);
await t.test("HTTP: sesión, CSRF, validación, idempotencia y consultas paginadas", async () => {
const http = f.app.listen(0, "127.0.0.1");
try {
await once(http, "listening");
const address = http.address();
assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const endpoint = `${base}/api/asset-network-share-publications/${company}`;
assert.equal((await fetch(endpoint)).status, 401);
const login = await fetch(`${base}/fixture-session`);
const cookie = login.headers.get("set-cookie")?.split(";")[0];
assert.ok(cookie);
const body = {
action: "publish",
assetId: nas.id.toPrimitive(),
resourceId: unknown.id.toPrimitive(),
};
const request = (
value: unknown,
headers = { cookie, origin: "http://localhost:5173", "x-csrf-token": "a".repeat(64) }
) =>
fetch(endpoint, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify(value),
});
assert.equal(
(
await request(body, {
cookie,
origin: "https://wrong.invalid",
"x-csrf-token": "a".repeat(64),
})
).status,
403
);
assert.equal(
(
await request(body, {
cookie,
origin: "http://localhost:5173",
"x-csrf-token": "wrong",
})
).status,
403
);
assert.equal(
(
await fetch(endpoint, {
method: "POST",
headers: {
cookie,
origin: "http://localhost:5173",
"content-type": "application/json",
},
body: JSON.stringify(body),
})
).status,
403
);
assert.equal(
(
await fetch(
`${base}/api/asset-network-share-publications/${UniqueID.generateNewID().toPrimitive()}`,
{ headers: { cookie } }
)
).status,
404
);
assert.equal((await request({ ...body, assetId: foreign.id.toPrimitive() })).status, 404);
assert.equal((await request({ ...body, operatorId: actor })).status, 400);
assert.equal(
(
await request({
action: "publish",
assetId: "invalid",
resourceId: unknown.id.toPrimitive(),
})
).status,
400
);
const created = await request(body);
assert.equal(created.status, 200);
const firstResponse = z
.object({ publication: NetworkSharePublicationSchema })
.parse(await created.json()).publication;
const secondResponse = z
.object({ publication: NetworkSharePublicationSchema })
.parse(await (await request(body)).json()).publication;
assert.equal(firstResponse.id, secondResponse.id);
const params = new URLSearchParams({
typeId: NETWORK_SHARE_PUBLICATION_TYPE_ID,
anchorType: "resources:resource",
anchorId: unknown.id.toPrimitive(),
direction: "incoming",
includeEnded: "true",
pageSize: "1",
});
const relations = await fetch(`${endpoint}/relationships?${params}`, {
headers: { cookie },
});
assert.equal(relations.status, 200);
const payload = z
.object({
page: RelationshipPageSchema,
summaries: z.array(
z.object({ summary: z.object({ reference: z.object({ id: z.uuid() }) }) })
),
})
.parse(await relations.json());
assert.ok(RelationshipPageSchema.safeParse(payload.page).success);
assert.equal(payload.summaries[0]?.summary.reference.id, nas.id.toPrimitive());
assert.equal(
(await fetch(`${endpoint}?operatorId=${actor}`, { headers: { cookie } })).status,
400
);
const list = await fetch(`${endpoint}?pageSize=1`, { headers: { cookie } });
assert.equal(list.status, 200);
assert.ok(NetworkSharePublicationPageSchema.safeParse(await list.json()).success);
} finally {
await new Promise<void>((resolve, reject) =>
http.close((error) => (error ? reject(error) : resolve()))
);
}
});
} finally {
try {
if (ready) {
await cleanupMembershipFixture(db, [company, otherCompany], actor);
}
} finally {
await db.close();
}
}
});

View File

@ -0,0 +1,534 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { test } from "node:test";
import type { ResourceInput } from "@metis/resources/common";
import { InventoryResponseSchema, NetworkShareDiagnosticsSchema } from "@metis/resources/common";
import { QueryTypes } from "sequelize";
import { AccessUseCases } from "../../../modules/companies/src/api/application/access.use-cases";
import {
AccessPersistenceMapper,
SequelizeAccessRepository,
} from "../../../modules/companies/src/api/infrastructure/persistence/access-persistence";
import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper";
import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository";
import { diagnoseNetworkShares } from "../../../modules/resources/src/api/application/network-share-diagnostics";
import { migrateNetworkShares } from "../../../modules/resources/src/api/infrastructure/persistence/network-share-migration";
import { NetworkShareReader } from "../../../modules/resources/src/api/infrastructure/persistence/network-share-reader";
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
import {
cleanupMembershipFixture,
prepareMembershipFixture,
} from "./asset-directory-memberships.fixture";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { verifyFolderCompatibility } from "./network-share-folder-compatibility.fixture";
test("MariaDB: SMB, unicidad vigente, historial, diagnóstico e integridad", async (t) => {
const environment = readDatabaseEnvironment(process.env);
assert.equal(environment.NODE_ENV, "test");
assert.equal(environment.DB_NAME, "metis_desk_test");
const database = createDatabase(environment);
const actor = UniqueID.generateNewID().toPrimitive();
const company = UniqueID.generateNewID().toPrimitive();
const otherCompany = UniqueID.generateNewID().toPrimitive();
let ready = false;
const referenceTable = `smb_fixture_refs_${company.replaceAll("-", "")}`;
let referencesReady = false;
try {
const fixture = await prepareMembershipFixture(database, "http://localhost:5173", actor, [
company,
otherCompany,
]);
ready = true;
await migrateNetworkShares(database);
const loaded = await fixture.resourceUseCases.get(company);
assert.ok(loaded.isSuccess);
const template = loaded.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:shared_folder"
);
const unc = loaded.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:unc"
);
const accessTemplate = loaded.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:access"
);
const reference = loaded.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:reference"
);
assert.ok(template && unc && accessTemplate && reference);
let integration: ResourceInput["integration"];
const input = (name: string, address: string, active = true) => ({
name,
templateId: template.id.toPrimitive(),
active,
notes: "Información conservada",
...(integration ? { integration } : {}),
characteristics: { [unc.id.toPrimitive()]: address },
});
const save = (name: string, address: string, active = true) => {
const { integration: _integration, ...values } = input(name, address, active);
return fixture.resourceUseCases.save(company, undefined, values, actor);
};
const count = async () => {
const rows = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM resources WHERE companyId=:company",
{ replacements: { company }, type: QueryTypes.SELECT }
);
return Number(rows[0]?.total);
};
const first = await save("Finanzas", String.raw`\\NAS-01\Finanzas`);
assert.ok(first.isSuccess && first.data.networkShare);
const id = first.data.resource.id.toPrimitive();
integration = first.data.resource.values.toPrimitive().integration;
await t.test("alta, presentación, lector público y aislamiento", async () => {
assert.equal(first.data.networkShare?.address, String.raw`\\NAS-01\Finanzas`);
assert.equal(first.data.networkShare?.normalizedAddress, String.raw`\\nas-01\finanzas`);
await database.transaction(async (transaction) => {
const reader = new NetworkShareReader(database, transaction);
const found = await reader.find(first.data.resource.companyId, first.data.resource.id);
assert.equal(found.getOrUndefined()?.capability, "network:smb-share");
assert.equal(
(await reader.find(UniqueID.generateNewID(), first.data.resource.id)).getOrUndefined(),
undefined
);
});
const other = await fixture.resourceUseCases.save(
otherCompany,
undefined,
{ ...input("Otro ámbito", String.raw`\\NAS-01\Finanzas`), integration: undefined },
actor
);
assert.ok(other.isSuccess);
});
await t.test("conflicto funcional, rollback, alias e inactivos", async () => {
const before = await count();
const duplicate = await save("Duplicado", String.raw`\\nas-01\FINANZAS`);
assert.ok(!duplicate.isSuccess);
assert.equal(duplicate.error.code, "SMB_ADDRESS_EXISTS");
assert.equal(await count(), before);
assert.ok((await save("Alias", String.raw`\\files.example.local\Finanzas`)).isSuccess);
assert.ok((await save("Histórico inactivo", String.raw`\\NAS-01\Finanzas`, false)).isSuccess);
const invalid = await save("Inválido", "C:\\Datos");
assert.ok(!invalid.isSuccess);
assert.equal(invalid.error.code, "INVALID_SMB_ADDRESS");
});
await t.test("edición atómica, UUID estable, idempotencia y revisión de UNC", async () => {
const conflict = await fixture.resourceUseCases.save(
company,
id,
input("Renombrado", String.raw`\\files.example.local\Finanzas`),
actor
);
assert.ok(!conflict.isSuccess);
const afterConflict = await fixture.resourceUseCases.get(company);
assert.ok(afterConflict.isSuccess);
assert.equal(
afterConflict.data.resources.find((item) => item.id.toPrimitive() === id)?.history.length,
1
);
const changed = await fixture.resourceUseCases.save(
company,
id,
input("Nombre independiente", String.raw`\\NAS-01\Otros\Equipo`),
actor
);
assert.ok(changed.isSuccess && changed.data.networkShare);
assert.equal(changed.data.resource.id.toPrimitive(), id);
assert.equal(changed.data.resource.history.length, 2);
assert.equal(
changed.data.resource.history[0]?.values.toPrimitive().characteristics[
unc.id.toPrimitive()
],
String.raw`\\NAS-01\Finanzas`
);
assert.equal(changed.data.networkShare.relativePath, "Equipo");
const repeated = await fixture.resourceUseCases.save(
company,
id,
input("Nombre independiente", String.raw`\\NAS-01\Otros\Equipo`),
actor
);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data.resource.history.length, 2);
});
await t.test(
"desactivación libera dirección y reactivación conflictiva conserva estado",
async () => {
const inactive = await fixture.resourceUseCases.setActive(company, id, false, actor);
assert.ok(inactive.isSuccess && inactive.data.networkShare);
const replacement = await save("Sustituto", String.raw`\\nas-01\otros\equipo`);
assert.ok(replacement.isSuccess);
const failed = await fixture.resourceUseCases.setActive(company, id, true, actor);
assert.ok(!failed.isSuccess);
assert.equal(failed.error.code, "SMB_ADDRESS_EXISTS");
assert.ok(
(
await fixture.resourceUseCases.setActive(
company,
replacement.data.resource.id.toPrimitive(),
false,
actor
)
).isSuccess
);
assert.ok((await fixture.resourceUseCases.setActive(company, id, true, actor)).isSuccess);
}
);
await t.test("concurrencia: una sola alta y ninguna raíz huérfana", async () => {
const before = await count();
const results = await Promise.all([
save("Concurrente A", String.raw`\\NAS\Concurrente`),
save("Concurrente B", String.raw`\\nas\CONCURRENTE`),
]);
assert.equal(results.filter((result) => result.isSuccess).length, 1);
assert.equal(await count(), before + 1);
const rejected = results.find((result) => !result.isSuccess);
assert.ok(rejected && !rejected.isSuccess);
assert.equal(rejected.error.code, "SMB_ADDRESS_EXISTS");
});
await t.test("restricciones SQL: compañía, 1:1, protocolo y unicidad", async () => {
const projection = {
resourceId: UniqueID.generateNewID().toPrimitive(),
companyId: company,
templateId: template.id.toPrimitive(),
address: String.raw`\\fake\share`,
};
const insert =
"INSERT INTO resource_network_shares (resourceId,companyId,templateId,normalizedAddress,activeAddress) VALUES (:resourceId,:companyId,:templateId,:address,:address)";
await assert.rejects(database.query(insert, { replacements: projection }));
await assert.rejects(
database.query(insert, {
replacements: { ...projection, resourceId: id, companyId: otherCompany },
})
);
await assert.rejects(
database.query(insert, { replacements: { ...projection, resourceId: id } })
);
await assert.rejects(
database.query("UPDATE resource_network_shares SET protocol='nfs' WHERE resourceId=:id", {
replacements: { id },
})
);
const unused = await save("Inactivo SQL", String.raw`\\NAS\Concurrente`, false);
assert.ok(unused.isSuccess);
await assert.rejects(
database.query(
"UPDATE resource_network_shares SET activeAddress=normalizedAddress WHERE resourceId=:id",
{ replacements: { id: unused.data.resource.id.toPrimitive() } }
)
);
});
await t.test(
"legado: ausente, inválido, válido, duplicado y candidato access; diagnóstico sin escritura",
async () => {
const legacyIds: string[] = [];
for (const [name, address] of [
["Legado sin UNC", ""],
["Legado inválido", "ruta vieja"],
["Legado válido", String.raw`\\NAS\Legado`],
["Legado duplicado", String.raw`\\nas\legado`],
]) {
assert.ok(name !== undefined && address !== undefined);
const original = await save(name, `\\\\NAS\\${name.replaceAll(" ", "")}`);
assert.ok(original.isSuccess);
const oldId = original.data.resource.id.toPrimitive();
legacyIds.push(oldId);
// Simula datos anteriores a 3.1 sin convertirlos al volver a guardarlos.
await database.query("DELETE FROM resource_network_shares WHERE resourceId=:id", {
replacements: { id: oldId },
});
const legacy = await fixture.resourceUseCases.save(
company,
oldId,
input(name, address),
actor
);
assert.ok(legacy.isSuccess && !legacy.data.networkShare);
}
const access = new AccessUseCases(
new SequelizeAccessRepository(
database,
new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()),
new AccessPersistenceMapper()
)
);
const type = await access.save(
company,
undefined,
{ kind: "resource_type", name: "SMB fixture", active: true },
actor
);
assert.ok(type.isSuccess);
const typeItem = type.data.workspace.items.find(
(item) => item.values.data.kind === "resource_type"
);
assert.ok(typeItem);
const typeId = typeItem.id.toPrimitive();
const legacyAccess = await fixture.resourceUseCases.save(
company,
undefined,
{
name: "Carpeta anterior access",
templateId: accessTemplate.id.toPrimitive(),
active: true,
notes: "",
characteristics: { [reference.id.toPrimitive()]: String.raw`\\NAS\Anterior` },
integration: {
kind: "access",
referenceFieldId: reference.id.toPrimitive(),
resourceTypeId: typeId,
systemId: null,
environmentId: null,
},
},
actor
);
assert.ok(legacyAccess.isSuccess && !legacyAccess.data.networkShare);
const before = await fixture.resourceUseCases.get(company);
assert.ok(before.isSuccess);
const diagnosis = NetworkShareDiagnosticsSchema.parse({
items: diagnoseNetworkShares(before.data),
});
const status = legacyIds.map((legacyId) =>
diagnosis.items.find((item) => item.resourceId === legacyId)
);
assert.equal(status[0]?.addressStatus, "missing");
assert.equal(status[1]?.addressStatus, "invalid");
assert.equal(status[2]?.addressStatus, "valid");
assert.equal(status[2]?.duplicate, true);
assert.equal(status[3]?.duplicate, true);
assert.ok(
diagnosis.items.some(
(item) => item.classification === "legacy-candidate" && item.requiresConversion
)
);
const after = await fixture.resourceUseCases.get(company);
assert.ok(after.isSuccess);
assert.deepEqual(
after.data.resources.map((item) => [item.id.toPrimitive(), item.history.length]),
before.data.resources.map((item) => [item.id.toPrimitive(), item.history.length])
);
}
);
await t.test(
"integración access existente y cambio de UNC conservan contexto y referencias",
async () => {
const current = await fixture.resourceUseCases.get(company);
assert.ok(current.isSuccess);
const old = current.data.resources.find(
(item) => item.values.toPrimitive().templateId === accessTemplate.id.toPrimitive()
);
assert.ok(old);
const values = old.values.toPrimitive();
const changed = await fixture.resourceUseCases.save(
company,
old.id.toPrimitive(),
{
...values,
characteristics: { [reference.id.toPrimitive()]: String.raw`\\NAS\Anterior2` },
},
actor
);
assert.ok(changed.isSuccess);
assert.deepEqual(
changed.data.resource.values.toPrimitive().integration,
values.integration
);
const projected = await database.query<{ reference: string }>(
"SELECT reference FROM access_resource_values WHERE ownerId=:id",
{ replacements: { id: old.id.toPrimitive() }, type: QueryTypes.SELECT }
);
assert.equal(projected[0]?.reference, String.raw`\\NAS\Anterior2`);
const ordinary = current.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:application"
);
assert.ok(ordinary);
const saved = await fixture.resourceUseCases.save(
company,
undefined,
{
name: "Aplicación ordinaria",
templateId: ordinary.id.toPrimitive(),
active: true,
notes: "",
characteristics: {},
},
actor
);
assert.ok(saved.isSuccess && !saved.data.networkShare);
assert.ok(await fixture.domain(company, `smb-${company.slice(0, 8)}.local`));
}
);
await t.test(
"FolderMapping conserva UNC, letra e historial y detecta dirección desactualizada",
async () => {
await verifyFolderCompatibility(database, fixture, company, actor);
}
);
await t.test(
"HTTP autenticado: contrato SMB y diagnóstico acotado sin aceptar parámetros",
async () => {
const server = fixture.app.listen(0, "127.0.0.1");
try {
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const url = `${base}/api/resources/${company}/network-share-diagnostics`;
assert.equal((await fetch(url)).status, 401);
const login = await fetch(`${base}/fixture-session`);
const cookie = login.headers.get("set-cookie")?.split(";")[0];
assert.ok(cookie);
const response = await fetch(url, { headers: { cookie } });
assert.equal(response.status, 200);
const diagnosis = NetworkShareDiagnosticsSchema.parse(await response.json());
assert.ok(diagnosis.items.every((item) => item.companyId === company));
assert.equal(
(await fetch(`${url}?operatorId=${actor}`, { headers: { cookie } })).status,
400
);
const inventory = await fetch(`${base}/api/resources/${company}`, {
headers: { cookie },
});
const data = InventoryResponseSchema.parse(await inventory.json());
assert.ok(
data.resources.some((item) => item.networkShare?.capability === "network:smb-share")
);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
}
);
await t.test(
"borrado sin referencias limpia proyección; una FK externa impide borrado",
async () => {
const removable = await save("Para borrar", String.raw`\\NAS\Temporal`);
assert.ok(removable.isSuccess);
const resourceId = removable.data.resource.id.toPrimitive();
await database.query(`CREATE TABLE ${referenceTable} (
id CHAR(36) BINARY PRIMARY KEY,
resourceId CHAR(36) BINARY NULL,
shareId CHAR(36) BINARY NULL,
FOREIGN KEY (resourceId) REFERENCES resources(id),
FOREIGN KEY (shareId) REFERENCES resource_network_shares(resourceId)
) ENGINE=InnoDB`);
referencesReady = true;
for (const column of ["resourceId", "shareId"]) {
await database.query(
`INSERT INTO ${referenceTable} (id,${column}) VALUES (:resourceId,:resourceId)`,
{ replacements: { resourceId } }
);
const blocked = await fixture.resourceUseCases.remove(company, resourceId);
assert.ok(!blocked.isSuccess);
assert.equal(blocked.error.code, "RESOURCE_IN_USE");
await database.query(`DELETE FROM ${referenceTable} WHERE id=:resourceId`, {
replacements: { resourceId },
});
}
assert.ok((await fixture.resourceUseCases.remove(company, resourceId)).isSuccess);
const rows = await database.query(
"SELECT resourceId FROM resource_network_shares WHERE resourceId=:id",
{ replacements: { id: resourceId }, type: QueryTypes.SELECT }
);
assert.equal(rows.length, 0);
}
);
} finally {
try {
if (referencesReady) {
await database.query(`DROP TABLE ${referenceTable}`);
}
if (ready) {
for (const companyId of [company, otherCompany]) {
await database.query("DELETE FROM resource_network_shares WHERE companyId=:companyId", {
replacements: { companyId },
});
for (const table of ["access_resource_values", "access_resource_type_values"]) {
await database.query(
`DELETE v FROM ${table} v JOIN access_items i ON i.id=v.ownerId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId`,
{ replacements: { companyId } }
);
}
await database.query(
"DELETE v FROM access_resource_revision_values v JOIN access_revisions r ON r.id=v.ownerId JOIN access_items i ON i.id=r.itemId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId",
{ replacements: { companyId } }
);
}
await cleanupMembershipFixture(database, [company, otherCompany], actor);
}
} finally {
await database.close();
}
}
});

View File

@ -0,0 +1,582 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { AccessFieldsDTO } from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { AccessUseCases } from "../../../modules/companies/src/api/application/access.use-cases";
import { buildAccessResponse } from "../../../modules/companies/src/api/application/access-response.builder";
import {
AccessPersistenceMapper,
SequelizeAccessRepository,
} from "../../../modules/companies/src/api/infrastructure/persistence/access-persistence";
import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper";
import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository";
import { ResourceUseCases } from "../../../modules/resources/src/api/application/resource.use-cases";
import { SequelizeResourceRepository } from "../../../modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository";
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
import {
cleanupMembershipFixture,
prepareMembershipFixture,
} from "./asset-directory-memberships.fixture";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
test("MariaDB: nuevas carpetas SMB integradas con accesos sin adoptar históricos", async (t) => {
const env = readDatabaseEnvironment(process.env);
assert.equal(env.NODE_ENV, "test");
assert.equal(env.DB_NAME, "metis_desk_test");
const database = createDatabase(env);
const actor = UniqueID.generateNewID().toPrimitive();
const company = UniqueID.generateNewID().toPrimitive();
const otherCompany = UniqueID.generateNewID().toPrimitive();
const personId = UniqueID.generateNewID().toPrimitive();
let ready = false;
try {
const fixture = await prepareMembershipFixture(database, "http://localhost:5173", actor, [
company,
otherCompany,
]);
ready = true;
const access = new AccessUseCases(
new SequelizeAccessRepository(
database,
new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()),
new AccessPersistenceMapper()
)
);
const initial = await fixture.resourceUseCases.get(company);
assert.ok(initial.isSuccess);
const template = initial.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:shared_folder"
);
const unc = initial.data.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:unc"
);
assert.ok(template && unc);
const input = (name: string, address = String.raw`\\NAS\Finanzas`) => ({
name,
templateId: template.id.toPrimitive(),
active: true,
notes: "",
characteristics: { [unc.id.toPrimitive()]: address },
});
const created = await fixture.resourceUseCases.save(
company,
undefined,
input("Finanzas"),
actor
);
assert.ok(created.isSuccess && created.data.networkShare);
const resourceId = created.data.resource.id.toPrimitive();
let current = created.data.resource.values.toPrimitive();
assert.ok(current.integration?.kind === "access");
const typeId = current.integration.resourceTypeId;
const add = async (values: AccessFieldsDTO) => {
const before = await access.get(company);
assert.ok(before.isSuccess);
const ids = new Set(before.data.workspace.items.map((item) => item.id.toPrimitive()));
const saved = await access.save(company, undefined, values, actor);
assert.ok(saved.isSuccess, saved.isSuccess ? "" : saved.error.code);
const item = saved.data.workspace.items.find((item) => !ids.has(item.id.toPrimitive()));
assert.ok(item);
return item.id.toPrimitive();
};
await t.test(
"alta automática: misma identidad, ninguna dependencia de dominio o publicador",
async () => {
const snapshot = await access.get(company);
assert.ok(snapshot.isSuccess);
const projected = buildAccessResponse(snapshot.data).items.find(
(item) => item.id === resourceId
);
assert.equal(projected?.values.kind, "resource");
assert.ok(projected?.values.kind === "resource");
assert.equal(projected.values.reference, current.characteristics[unc.id.toPrimitive()]);
assert.equal(projected.values.systemId, null);
assert.equal(projected.values.environmentId, null);
assert.equal(projected.history[0]?.id, created.data.resource.history[0]?.id.toPrimitive());
const domains = await database.query(
"SELECT resourceId FROM resource_directory_domains WHERE companyId=:company",
{ replacements: { company }, type: QueryTypes.SELECT }
);
const assets = await database.query("SELECT id FROM assets WHERE companyId=:company", {
replacements: { company },
type: QueryTypes.SELECT,
});
assert.equal(domains.length, 0);
assert.equal(assets.length, 0);
}
);
await t.test(
"renombrar el tipo de acceso conserva su UUID para las siguientes altas",
async () => {
const renamed = await access.save(
company,
typeId,
{ kind: "resource_type", name: "Carpetas documentales", active: true },
actor
);
assert.ok(renamed.isSuccess);
const next = await fixture.resourceUseCases.save(
company,
undefined,
input("Segunda carpeta", String.raw`\\NAS\Segunda`),
actor
);
assert.ok(next.isSuccess);
const integration = next.data.resource.values.toPrimitive().integration;
assert.ok(integration?.kind === "access");
assert.equal(integration.resourceTypeId, typeId);
}
);
await t.test(
"el cliente no puede imponer buzón, otro tipo ni contexto; se revierte el tipo preparado",
async () => {
for (const integration of [
{
kind: "mailbox",
addressFieldId: unc.id.toPrimitive(),
contextFieldId: unc.id.toPrimitive(),
mailboxKind: "corporate",
accountId: null,
} as const,
{
kind: "access",
referenceFieldId: unc.id.toPrimitive(),
resourceTypeId: UniqueID.generateNewID().toPrimitive(),
systemId: null,
environmentId: null,
} as const,
{
kind: "access",
referenceFieldId: unc.id.toPrimitive(),
resourceTypeId: typeId,
systemId: UniqueID.generateNewID().toPrimitive(),
environmentId: null,
} as const,
]) {
const failed = await fixture.resourceUseCases.save(
otherCompany,
undefined,
{ ...input("Incompatible"), integration },
actor
);
assert.ok(!failed.isSuccess);
assert.equal(failed.error.code, "SMB_ACCESS_INCOMPATIBLE");
}
const rows = await database.query(
"SELECT i.id FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company",
{ replacements: { company: otherCompany }, type: QueryTypes.SELECT }
);
assert.equal(rows.length, 0);
}
);
await t.test(
"renombrado, UNC y estados sincronizan valores y UUID de todas las revisiones",
async () => {
const saved = await fixture.resourceUseCases.save(
company,
resourceId,
{
...current,
name: "Documentación",
characteristics: {
...current.characteristics,
[unc.id.toPrimitive()]: String.raw`\\NAS\Documentacion`,
},
},
actor
);
assert.ok(saved.isSuccess);
current = saved.data.resource.values.toPrimitive();
for (const active of [false, true]) {
assert.ok(
(await fixture.resourceUseCases.setActive(company, resourceId, active, actor)).isSuccess
);
}
const resource = await fixture.resourceUseCases.get(company);
const snapshot = await access.get(company);
assert.ok(resource.isSuccess && snapshot.isSuccess);
const root = resource.data.resources.find((item) => item.id.toPrimitive() === resourceId);
const projection = snapshot.data.workspace.items.find(
(item) => item.id.toPrimitive() === resourceId
);
assert.ok(root && projection);
assert.equal(projection.values.data.kind, "resource");
assert.ok(projection.values.data.kind === "resource");
assert.equal(projection.values.data.name, "Documentación");
assert.equal(projection.values.data.reference, String.raw`\\NAS\Documentacion`);
assert.equal(projection.values.data.active, true);
assert.deepEqual(
projection.history.map((item) => item.id.toPrimitive()),
root.history.map((item) => item.id.toPrimitive())
);
}
);
await t.test(
"necesidad y concesiones AD y NAS local: cobertura documental y borrado protegido",
async () => {
await database.query(
"INSERT INTO company_contacts (id,companyId,name,isContact,active) VALUES (:personId,:company,'SMB access fixture',0,1)",
{ replacements: { personId, company } }
);
const ad = await add({ kind: "system", name: "AD fixture", active: true });
const nas = await add({ kind: "system", name: "NAS local fixture", active: true });
const adEnvironment = await add({
kind: "environment",
name: "empresa.local",
systemId: ad,
active: true,
});
const nasEnvironment = await add({
kind: "environment",
name: "NAS-01",
systemId: nas,
active: true,
});
const permission = await add({
kind: "permission",
name: "Lectura",
resourceTypeId: typeId,
active: true,
description: null,
});
const need = await add({
kind: "need",
personId,
resourceId,
permissionIds: [permission],
startDate: "2020-01-01",
endDate: null,
notes: null,
});
const adAccount = await add({
kind: "account",
systemId: ad,
environmentId: adEnvironment,
login: "EMPRESA\\juan",
usage: "personal",
active: true,
expiresOn: null,
notes: null,
});
const nasAccount = await add({
kind: "account",
systemId: nas,
environmentId: nasEnvironment,
login: "juan_local",
usage: "personal",
active: true,
expiresOn: null,
notes: null,
});
const adPerson = await add({
kind: "account_person",
accountId: adAccount,
personId,
startDate: "2020-01-01",
endDate: null,
configuration: "configured",
});
const nasPerson = await add({
kind: "account_person",
accountId: nasAccount,
personId,
startDate: "2020-01-01",
endDate: null,
configuration: "configured",
});
const group = await add({
kind: "group",
systemId: ad,
environmentId: adEnvironment,
name: "DL_Finanzas_R",
identifier: "DL_Finanzas_R",
active: true,
description: null,
templateId: null,
});
const membership = await add({
kind: "membership",
accountId: adAccount,
groupId: group,
startDate: "2020-01-01",
endDate: null,
configuration: "configured",
});
const groupGrant = await add({
kind: "grant",
resourceId,
permissionId: permission,
accountId: null,
groupId: group,
startDate: "2020-01-01",
endDate: null,
configuration: "configured",
});
const directGrant = await add({
kind: "grant",
resourceId,
permissionId: permission,
accountId: nasAccount,
groupId: null,
startDate: "2020-01-01",
endDate: null,
configuration: "configured",
});
await add({
kind: "coverage",
needId: need,
grantId: groupGrant,
accountPersonId: adPerson,
membershipId: membership,
startDate: "2020-01-01",
endDate: null,
});
await add({
kind: "coverage",
needId: need,
grantId: directGrant,
accountPersonId: nasPerson,
membershipId: null,
startDate: "2020-01-01",
endDate: null,
});
const covered = await access.get(company);
assert.ok(covered.isSuccess);
assert.equal(
buildAccessResponse(covered.data).coverage.find((item) => item.needId === need)?.covered,
true
);
const before = covered.data.workspace.items
.filter((item) => [need, groupGrant, directGrant].includes(item.id.toPrimitive()))
.map((item) => [item.id.toPrimitive(), item.history.length]);
assert.ok(
(await fixture.resourceUseCases.setActive(company, resourceId, false, actor)).isSuccess
);
const inactive = await access.get(company);
assert.ok(inactive.isSuccess);
assert.equal(
buildAccessResponse(inactive.data).coverage.find((item) => item.needId === need)?.covered,
false
);
assert.deepEqual(
inactive.data.workspace.items
.filter((item) => [need, groupGrant, directGrant].includes(item.id.toPrimitive()))
.map((item) => [item.id.toPrimitive(), item.history.length]),
before
);
assert.ok(
(await fixture.resourceUseCases.setActive(company, resourceId, true, actor)).isSuccess
);
const blocked = await fixture.resourceUseCases.remove(company, resourceId);
assert.ok(!blocked.isSuccess);
assert.equal(blocked.error.code, "RESOURCE_IN_USE");
}
);
await t.test(
"fallo después de escribir acceso revierte Resource, SMB, AccessItem y tipo",
async () => {
const service = new ResourceUseCases(
new SequelizeResourceRepository(database, undefined, (transaction) => {
const writer = fixture.resourceAccess(transaction);
return {
ensureSmbType: (value) => writer.ensureSmbType(value),
project: async (value) => {
await writer.project(value);
throw new Error("Fallo controlado de proyección access");
},
};
})
);
await assert.rejects(
service.save(otherCompany, undefined, input("Rollback"), actor),
/Fallo controlado/
);
for (const sql of [
"SELECT id FROM resources WHERE companyId=:company",
"SELECT resourceId FROM resource_network_shares WHERE companyId=:company",
"SELECT i.id FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company",
]) {
assert.equal(
(
await database.query(sql, {
replacements: { company: otherCompany },
type: QueryTypes.SELECT,
})
).length,
0
);
}
}
);
await t.test(
"carpeta anterior sin integración: lectura, edición y estado no la adoptan",
async () => {
const old = await fixture.resourceUseCases.save(
company,
undefined,
input("Histórica", String.raw`\\NAS\Historica`),
actor
);
assert.ok(old.isSuccess);
const id = old.data.resource.id.toPrimitive();
// Simula una carpeta de 3.1, cuya proyección SMB existía sin integración access.
await database.query("DELETE FROM access_references WHERE itemId=:id", {
replacements: { id },
});
await database.query("DELETE FROM access_resource_values WHERE ownerId=:id", {
replacements: { id },
});
await database.query(
"DELETE v FROM access_resource_revision_values v JOIN access_revisions r ON r.id=v.ownerId WHERE r.itemId=:id",
{ replacements: { id } }
);
await database.query("DELETE FROM access_revisions WHERE itemId=:id", {
replacements: { id },
});
await database.query("DELETE FROM access_items WHERE id=:id", { replacements: { id } });
for (const table of ["resources", "resource_revisions"]) {
await database.query(
`UPDATE ${table} SET integrationKind=NULL,referenceFieldId=NULL,resourceTypeId=NULL,systemId=NULL,environmentId=NULL WHERE ${table === "resources" ? "id" : "resourceId"}=:id`,
{ replacements: { id } }
);
}
const saved = await fixture.resourceUseCases.save(
company,
id,
input("Histórica renombrada", String.raw`\\NAS\Historica2`),
actor
);
assert.ok(saved.isSuccess && saved.data.networkShare);
assert.equal(saved.data.resource.values.toPrimitive().integration, undefined);
assert.ok((await fixture.resourceUseCases.setActive(company, id, false, actor)).isSuccess);
assert.equal(
(
await database.query("SELECT id FROM access_items WHERE id=:id", {
replacements: { id },
type: QueryTypes.SELECT,
})
).length,
0
);
await database.query("DELETE FROM resource_network_shares WHERE resourceId=:id", {
replacements: { id },
});
const older = await fixture.resourceUseCases.save(
company,
id,
input("Anterior sin especialización", "ruta documental antigua"),
actor
);
assert.ok(older.isSuccess);
assert.equal(older.data.networkShare, undefined);
assert.equal(older.data.resource.values.toPrimitive().integration, undefined);
}
);
} finally {
try {
if (ready) {
await cleanupSmbAccess(database, [company, otherCompany], personId, company, actor);
}
} finally {
await database.close();
}
}
});
async function cleanupSmbAccess(
database: Sequelize,
companyIds: readonly string[],
personId: string,
company: string,
actor: string
) {
for (const companyId of companyIds) {
await database.query("DELETE FROM resource_network_shares WHERE companyId=:company", {
replacements: { company: companyId },
});
for (const table of ["access_need_permissions", "access_need_revision_permissions"]) {
const history = table.includes("revision");
await database.query(
`DELETE v FROM ${table} v ${history ? "JOIN access_revisions r ON r.id=v.ownerId JOIN access_items i ON i.id=r.itemId" : "JOIN access_items i ON i.id=v.ownerId"} JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company`,
{ replacements: { company: companyId } }
);
}
for (const kind of [
"coverage",
"grant",
"membership",
"account_person",
"need",
"permission",
"account",
"group",
"environment",
"system",
]) {
await database.query(
`DELETE v FROM access_${kind}_values v JOIN access_items i ON i.id=v.ownerId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company`,
{ replacements: { company: companyId } }
);
await database.query(
`DELETE v FROM access_${kind}_revision_values v JOIN access_revisions r ON r.id=v.ownerId JOIN access_items i ON i.id=r.itemId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company`,
{ replacements: { company: companyId } }
);
}
await database.query(
"DELETE v FROM access_person_references v JOIN access_items i ON i.id=v.itemId JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:company",
{ replacements: { company: companyId } }
);
}
await database.query("DELETE FROM company_contacts WHERE id=:personId AND companyId=:company", {
replacements: { personId, company },
});
await cleanupMembershipFixture(database, companyIds, actor);
}

View File

@ -148,7 +148,11 @@ test("MariaDB: proveedor real, lotes, seguridad, SQL paginado y transiciones de
await t.test("registro estático y consultas vacías desde ambos extremos", async () => {
assert.deepEqual(runtime.registry.providers, ["assets", "resources"]);
assert.deepEqual(runtime.registry.capabilities, ["directory:domain"]);
assert.deepEqual(runtime.registry.capabilities, ["directory:domain", "network:smb-share"]);
assert.deepEqual(
runtime.registry.definitions.map((definition) => definition.typeId),
["assets:directory-membership", "assets:network-share-publication"]
);
assert.ok(Object.isFrozen(runtime.registry.definitions[0]?.activeCardinality));
for (const anchor of [firstAsset, domainRef]) {
const result = await service.list(query(anchor), context);

View File

@ -101,3 +101,7 @@ ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas P
## Asistencia técnica remota
SupportConnection delimita cada método de asistencia de un activo de una compañía. SupportValues encapsula la referencia UniqueID al recurso de asistencia y los valores de configuración; SupportConnectionRevision conserva las instantáneas con actor y fecha. Las herramientas son agregados Resource de la compañía, creados desde una plantilla con uso Asistencia remota; no tienen un catálogo paralelo. Todas las raíces y revisiones nuevas tienen UUID v7. Consultar [Asistencia técnica remota](../asistencia-tecnica-remota.md).
## Entornos de identidad local
LocalIdentityEnvironment es una DomainEntity interna de Companies con UUID v7 y mapper bidireccional. Clasifica y vincula un AccessItem(environment) a un Asset por sus identidades estables, con propietario inmutable y trazabilidad de alta y retirada. No sustituye el entorno ni añade una entidad principal de cuentas. FolderMappingValues incorpora la referencia opcional UniqueID de la cuenta documental; sus revisiones preservan cada elección y la cobertura por cuenta se calcula al consultar. Véase [Identidades locales y cuentas de carpetas](../identidades-locales-y-cuentas-de-carpetas.md).

View File

@ -0,0 +1,146 @@
# Identidades locales y cuentas de carpetas v1 — paso 3.4
## Modelo y alcance
Companies conserva AccessWorkspace como propietario de sistemas, entornos, cuentas, grupos, pertenencias, concesiones, necesidades y cobertura. Se incorporan dos capacidades acotadas:
1. Una asociación estructurada y clasificada `AccessItem(environment) → Asset`, para ámbitos locales de un dispositivo.
2. La cuenta documental opcional de cada FolderMapping, con una evaluación de cobertura específica para esa cuenta.
No se crean nuevas cuentas, grupos ni entidades principales. `LocalIdentityEnvironment` es una entidad interna de asociación con UUID v7 propio y mapper; no sustituye ni duplica el entorno. Su discriminador estable es `identityScope: "local-device"`. No depende de nombres como «Local», «NAS» o «Windows» ni de la etiqueta del activo.
No se implementan autenticación SMB, secretos, sincronización LDAP, descubrimiento de cuentas ni comprobación de permisos efectivos SMB/NTFS. Los modelos SMB y de publicación de los pasos 3.1–3.3 mantienen su significado.
## Entornos locales
`access_local_identity_environments` pertenece a Companies. Cada fila conserva UUID, compañía, entorno, activo propietario, sistema, ámbito local, actor/fecha de creación y actor/fecha de retirada.
- Un entorno tiene como máximo un vínculo propietario, incluso histórico. No puede trasladarse a otro activo, cambiar silenciosamente de sistema ni reutilizarse como otro ámbito.
- Un activo admite varios entornos. No se añade unicidad activo/sistema: AccessWorkspace ya distingue los entornos por nombre dentro de un sistema y no existe un contrato que limite todos esos contextos a uno por dispositivo. Se conserva esa posibilidad de separación explícita.
- La asociación inicial exige activo existente, operativo y de la misma compañía. No exige AD, sistema operativo ni categoría; admite NAS, sobremesa y portátil.
- Se puede crear un entorno asociado o clasificar/asociar uno existente compatible. Sus cuentas, grupos y revisiones se conservan.
- Los entornos AD registrados y cualquier entorno del sistema estructurado de AD se rechazan como locales. La comprobación utiliza las clasificaciones del paso 2.1, nunca nombres. El preparador AD solo reutiliza entornos clasificados como AD; no adopta entornos locales.
- Un sistema visible del catálogo global puede utilizarse conforme al contrato existente de AccessWorkspace. El entorno siempre reside en el espacio de la compañía; no se aceptan sistemas de otras compañías.
- Renombrar el activo no renombra el entorno ni las cuentas. La asociación conserva el UUID del activo.
Retirar el vínculo conserva la clasificación y el propietario históricos, con cierre idempotente. No desactiva el entorno ni sus cuentas/grupos: el ciclo de vida de esos registros sigue gestionándose en AccessWorkspace. Tampoco permite reasignar ese entorno a otro activo o reabrir el vínculo; para un nuevo ámbito se crea otro entorno. Desactivar el activo no retira el vínculo automáticamente. Los reintentos de asociación devuelven el mismo vínculo vigente cuando el activo sigue siendo elegible; los vínculos retirados permanecen inmutables.
### Persistencia, transacciones y borrado
Las FK de entorno y sistema apuntan a `access_items(id,workspaceId,kind)`, identidades estables. Nunca apuntan a `access_environment_values`, `access_system_values` ni otras filas de valores que se eliminan y reconstruyen durante una actualización. Las FK y CHECK aseguran:
- Tipo `environment` / `system` y discriminador `local-device`.
- Espacio y compañía del entorno.
- Pareja `(companyId,assetId)` válida en Assets.
- Unicidad permanente de `environmentId`.
- Coherencia entre cierre, actor y fechas; actores existentes.
La compatibilidad entre sistema y entorno, visibilidad del sistema y exclusión de AD se comprueban transaccionalmente. El modelo de AccessItem ya hace inmutables el tipo y el sistema de un entorno existente; las FK impiden modificar lateralmente el tipo o el espacio de una identidad referenciada. No se añade un segundo mecanismo de edición de AccessItem.
La migración tiene dos etapas por el orden de composición existente: `migrateAccess` crea la tabla con sus referencias a Access e Identity; `initializeLocalIdentities` completa la FK de Assets una vez inicializado ese módulo, antes de registrar sus comandos. Ambas etapas son repetibles; no hay `sync({ alter: true })`, adopciones ni conversión de entornos existentes.
Las mutaciones bloquean catálogo global de Access y compañía siguiendo el orden existente. El lector mínimo público de Assets comprueba compañía/estado y bloquea la fila dentro de la misma transacción. Crear entorno, su revisión y asociación es atómico: si falla insertar el vínculo, se revierte también AccessWorkspace. La unicidad SQL actúa como garantía final ante concurrencia.
Las referencias presentes e históricas impiden el borrado físico de Asset mediante el mecanismo existente que inspecciona FK externas. No hay cascadas destructivas, borrado de cuentas ni eliminación pública del historial de asociación.
## Cuenta de FolderMapping
`FolderMapping.accessAccountId` documenta la cuenta usada para acceder a la carpeta desde un perfil. Es independiente de `WorkProfile.accountId`, que sigue representando la cuenta asociada al uso del ordenador. Ambas pueden coincidir, diferir o no estar indicadas. No se deducen cuentas del perfil, login de la persona, dominio, hostname UNC ni cobertura.
El schema de entrada admite:
| Entrada al editar | Efecto |
| --- | --- |
| Campo ausente | Conserva la cuenta anterior. |
| `accessAccountId: null` | Retira la cuenta indicada. |
| UUID | Establece o cambia la cuenta tras validarla. |
En un alta sin cuenta y al leer registros anteriores se devuelve `null`. Todos los cambios reales se comparan semánticamente y generan las revisiones existentes; un cambio idéntico conserva la revisión. La cuenta de cada revisión se mantiene al cambiar o retirar la actual. La migración no rellena historiales con cuentas inferidas.
Una selección nueva debe ser un AccessItem de tipo `account`, visible en la compañía, activo, no caducado y con sistema/entorno activos. Debe tener una relación `account_person` vigente con la persona del perfil y configuración `pending`, `configured` o NULL cuando el contrato la exige para una cuenta técnica. Se utiliza la identidad de la relación, no la coincidencia de login. Un grupo, sistema, cuenta ajena, cuenta inexistente o asociada solo a otra persona produce `FOLDER_ACCOUNT_INCOMPATIBLE`.
Las cuentas compartidas son válidas si están explícitamente asociadas a esa persona; pueden seguir asociadas a otras personas según las reglas actuales. Las cuentas técnicas pueden documentarse si existe su relación personal explícita compatible; el modelo actual prohíbe utilizarlas como fundamento de Coverage, por lo que su señal específica será `not-covered`. No se amplía ni se elude esa política para atribuir permisos. Una cuenta técnica sin relación personal no se selecciona arbitrariamente.
Las ediciones que conservan una cuenta cuya disponibilidad cambió no la borran ni impiden corregir notas o retirar la configuración. La cobertura derivada refleja la pérdida de disponibilidad. Una nueva selección/cambio vuelve a validar la cuenta.
### Restricciones SQL y registros previos
`migrateFolderMappingAccounts`, llamada por `migrateFolderMappings`, añade a la configuración y a sus revisiones `accessAccountId` nullable. Añade los metadatos técnicos `accessAccountWorkspaceId` nullable y `accessAccountKind='account'` para construir FK compuestas hacia las raíces estables de AccessItem y AccessWorkspace. Se exige que cuenta y espacio sean ambos NULL o ambos estén indicados.
Las revisiones incorporan `companyId`, derivado únicamente de su FolderMapping, para reforzar compañía mediante FK compuestas. Ese dato técnico no modifica los valores históricos ni afirma que hubiera una cuenta conocida. La FK `(mappingId,companyId)` mantiene cada revisión dentro de su configuración. Las restricciones impiden cuentas de otra compañía y subtipos distintos de account tanto en el estado actual como en el historial, sin referenciar valores reconstruibles.
## Cobertura documental específica
Se conserva **`permissionsCovered` con su semántica previa**, cobertura general de la necesidad para la persona. Sus consumidores existentes son el listado de FolderMapping y el resumen de entorno de trabajo; no se reinterpreta ese booleano ni se cambia su presentación en este paso.
La nueva propiedad `accessAccountCoverage` tiene tres valores:
- `unknown`: no se ha indicado cuenta.
- `covered`: las vías de Coverage válidas de esa cuenta cubren todas las autorizaciones de la necesidad.
- `not-covered`: la cuenta indicada no dispone de esa cobertura documental conforme a las reglas actuales.
`folderAccountCoverage` reutiliza `projectAccess`, sin crear otro motor de concesiones. Restringe las vías de Coverage por la relación `account_person` exacta de cuenta y persona, necesidad y recurso. Verifica que la concesión directa apunta a esa cuenta o que la pertenencia directa corresponde a esa cuenta y al grupo de la concesión. No acepta vías de otra cuenta de la misma persona ni grupos anidados. Conserva el requisito de Coverage explícito del modelo actual: la mera existencia de un Grant no crea automáticamente Coverage.
Se excluyen entradas cuyo inicio todavía no llegó y se respetan los estados de cierre/configuración de la proyección de Access existente. La necesidad debe corresponder al recurso y persona del mapeo; su recurso y demás referencias deben seguir disponibles y la UNC debe coincidir con la vigente.
La señal se calcula al consultar, no se guarda como autorización persistida:
| Cambio | Efecto en la siguiente lectura |
| --- | --- |
| La cuenta abandona el grupo | La vía por esa pertenencia deja de cubrir. |
| Concesión o Coverage finalizados | Se pierde esa vía; solo otras vías válidas de la misma cuenta pueden cubrir. |
| Cuenta desactivada/caducada | `not-covered`; se conserva su referencia documental. |
| Necesidad finalizada | `not-covered`. |
| Relación con la persona finalizada | `not-covered`, aunque la cuenta permanezca activa. |
| Recurso SMB desactivado o UNC cambiada | `not-covered`; la revisión del mapeo conserva su información previa. |
| Cuenta retirada del mapeo | `unknown`; no se sustituye por la del perfil. |
`covered` no confirma acceso efectivo, autenticación, disponibilidad ni conectividad. Tampoco cambia concesiones, necesidades, Coverage, dominios o publicación del recurso.
## Contratos y HTTP
La composición de Companies expone `initializeLocalIdentities`, que recibe una factoría transaccional del lector público mínimo de Assets. `ILocalIdentityAssetReader` solo maneja UniqueID y disponibilidad, sin importar repositorios privados ni ORM en Application. Assets expone `services.localIdentities(transaction)`. Se mantiene la dirección Companies → API pública de Assets y la composición explícita del servidor, sin dependencias circulares, Service Locator ni nuevo registry.
Rutas de identidad local, autenticadas y con validación estricta:
- `POST /api/local-identity-environments/:companyId`:
- `{ action: "create", assetId, systemId, name }`.
- `{ action: "associate", assetId, environmentId }`.
- `{ action: "retire", environmentId }`.
- Respuesta `{ environment }` con identidad y trazabilidad del vínculo.
- `GET /api/local-identity-environments/:companyId`: filtros `assetId?`, `environmentId?`, `includeEnded=true|false` (false por defecto), `pageSize` (1–100; 50 por defecto), `afterId?`. Respuesta `{ environments, nextAfterId }`. El keyset SQL ordena por UUID y limita antes de devolver datos. El filtro por entorno consulta su propietario; el filtro por activo lista sus ámbitos locales. `afterId` es una continuación UUID, siempre acotada por los filtros y compañía actuales.
Las rutas actuales `GET /api/folder-mappings/:companyId` y `POST /api/folder-mappings/:companyId[/:id/update]` leen y escriben la cuenta y devuelven su señal específica mediante sus contratos existentes. No hay API genérica de relaciones.
El actor se toma de sesión. Las escrituras están sujetas al control global de origen y CSRF de Identity. Se reutiliza la autorización de operadores autenticados existente, con comprobaciones de compañía y pertenencia en aplicación/persistencia. No se acepta `operatorId` del navegador ni se exponen datos de otra compañía. Los comandos locales reutilizan los errores tipados de Access: 400 para entrada inválida, 404 para compañía/identidad no encontrada, 409 para incompatibilidad, estado o conflicto; errores inesperados siguen el middleware HTTP central.
## Archivos principales y preparación para 3.5
- Contratos: `modules/companies/src/common/local-identity-environments.ts` y `folder-mappings.ts`.
- Modelo y planificación: `local-identity-environment.entity.ts`, `local-identity-plan.ts`, `local-identity.use-cases.ts`.
- Persistencia: `local-identity-migration.ts`, `local-identity.mapper.ts`, `sequelize-local-identity.repository.ts`, `folder-mapping-account-migration.ts` y ampliación de snapshots/repositorio de FolderMapping.
- Cobertura y validación: `folder-mapping-account-policy.ts`, `folder-mapping-input.mapper.ts`, `folder-mapping-response.builder.ts` y `folder-mapping-values.vo.ts`.
- Lector Assets: `modules/assets/src/api/infrastructure/persistence/local-identity-asset-reader.ts`.
- Composición/rutas: módulos Companies/Assets, `identity-runtime.ts` y `local-identity.routes.ts`.
- Pruebas: `local-identity-plan.test.ts`, `folder-mapping-account.test.ts` y `apps/server/src/local-identities-folder-mapping.integration.test.ts`.
No se añade administración visual de identidades locales ni selector de cuenta en esta fase. El formulario actual conserva la cuenta recibida aunque todavía no la muestre; las ediciones que omiten el campo la preservan también en servidor. Los DTO y endpoints preparan el selector contextual y las vistas de Activo/Recurso/Persona/Perfil del paso 3.5.
## Verificación
Las integraciones usan exclusivamente `metis_desk_test`, con datos temporales identificados por UUID y limpieza propia, sin desactivar FK. La suite nueva comprueba NAS, PC, portátil, cuentas homónimas, independencia de AD, exclusión de AD, compañía/subtipo en SQL, renombrado, retirada histórica, rollback, concurrencia, cuenta desconocida/alternativa/compartida/técnica, conservación y retirada, revisiones, cobertura directa y por grupo, estados derivados y HTTP con sesión, origen y CSRF.
Comando específico: `pnpm --filter @metis/server test:local-identities-folder-mapping`. Se ejecuta también la regresión de los pasos 2.1–3.3 y las suites afectadas de Companies, Assets y Resources.
Incidencias previas que se mantienen fuera del alcance: dos expectativas de texto en `companies-page.test.tsx` (líneas 180 y 210) y TS2375 en `packages/shadcn-ui/src/components/calendar.tsx:165`. No se modifica vendor ni esas expectativas para conseguir resultados verdes.
### Resultados de esta entrega
- Typecheck de Companies, Assets, Resources, servidor y web: correcto (`pnpm --filter … typecheck`).
- Companies: 180/182 pruebas; las únicas dos fallidas son las expectativas de texto previas indicadas arriba. Se incluyen nueve pruebas nuevas de planificación local, valores de FolderMapping y cobertura por cuenta.
- Assets: 131/131. Resources: 80/80. Los scripts se ejecutaron con `--test-isolation=none` para comprobar todos los casos.
- MariaDB: 11/11 casos de la suite nueva y 45/45 casos de regresión de los pasos 2.1–3.3. La ejecución conjunta inicial dio 55/55 y se repitió la suite nueva completa tras añadir el caso de concurrencia de asociación, con 11/11.
- Biome y ESLint sobre los ficheros nuevos/modificados del trabajo acumulado: correctos, sin avisos. Build del servidor y `git diff --check`: correctos.
- Grafo de dependencias de producción: 13 paquetes sin ciclos. No hay nuevas dependencias.
No se han ejecutado migraciones ni comandos de negocio contra compañías reales. La integración visual contextual y la selección de cuenta se mantienen para el paso 3.5.

View File

@ -0,0 +1,101 @@
# Publicación de recursos compartidos SMB v1
## Alcance y propiedad
Assets es propietario de `assets:network-share-publication`: **Asset → publica → Resource con capacidad `network:smb-share`**. Resources conserva el UUID, la UNC, la proyección SMB y su integración con AccessWorkspace. La publicación es un hecho documental registrado por un operador; no verifica disponibilidad, conectividad, permisos ni almacenamiento físico. Un alias UNC no identifica necesariamente al equipo publicador.
Un activo puede publicar múltiples carpetas. Cada recurso tiene cero o un publicador vigente y varios históricos. Un publicador desconocido se representa por ausencia de publicación vigente, sin activos ficticios. Nunca se infiere el publicador del hostname de la UNC.
No existe una clasificación de Assets que certifique capacidad real de servir SMB. Por ello se permite seleccionar explícitamente cualquier activo existente de la compañía con estado `operational`, `available` o `in_use`, siguiendo la política operativa existente. Esto admite Windows Server, NAS, equipos de sobremesa, portátiles y Linux con Samba, sin exigir sistema operativo, dominio, rol `server`/`storage` ni coincidencia entre nombre y UNC. La selección declara la relación; no descubre servicios.
## Modelo e integridad
La migración explícita `migrateNetworkSharePublications` crea `asset_network_share_publications`; no convierte recursos históricos ni crea publicaciones automáticamente. Se ejecuta en la composición de Assets cuando se inyecta el lector público SMB de Resources.
Cada periodo es un `NetworkSharePublication`, con UUID v7 propio, compañía, activo, recurso, actor y fecha de inicio; al finalizar conserva su identidad y añade actor y fecha de cierre. El traslado crea otro periodo con `predecessorId`, sin cambiar el UUID del Resource. El contrato de historial es `lifecycle`: los periodos y su enlace de sucesión conservan el alta, cierre y traslado; no se introduce un historial universal ni una tabla paralela de revisiones.
MariaDB refuerza:
- FK `(companyId,assetId)` hacia Assets y `(resourceId,companyId)` hacia `resource_network_shares`: exige destino SMB estructurado y evita referencias entre compañías.
- `UNIQUE(activeResourceId)`: solo el periodo vigente guarda ahí su `resourceId`; los finalizados tienen NULL.
- CHECK de coherencia entre `endedAt`, `endedBy` y `activeResourceId`, y cierre posterior o igual al inicio.
- `UNIQUE(predecessorId)` y FK compuesta al periodo anterior de la misma compañía y recurso: un periodo tiene como máximo un sucesor de traslado.
- FK de actores a Identity, índices `(companyId,assetId,id)` y `(companyId,resourceId,id)`, sin cascadas destructivas.
No hay comando público para borrar ni reescribir periodos históricos. Los mecanismos existentes de protección de borrado inspeccionan las FK externas de Assets y de la proyección SMB de Resources. Bloquean el borrado físico incluso cuando solo quedan periodos finalizados (`ASSET_IN_USE` / `RESOURCE_IN_USE`). No es necesaria una dependencia inversa Resources → Assets.
## Comandos, concurrencia e idempotencia
`NetworkSharePublicationUseCases` valida entradas estrictas y delega la transacción al repositorio especializado de Assets. Consume únicamente `INetworkShareReader`, mediante la factoría pública de Resources ligada a la transacción.
| Acción | Entrada adicional | Resultado |
| --- | --- | --- |
| `publish` | `assetId`, `resourceId` | Crea un periodo si no hay publicador; devuelve el mismo periodo si ya publica ese activo; otro publicador produce `PUBLICATION_CONFLICT`. |
| `end` | `publicationId` | Cierra ese periodo. Si ya estaba cerrado devuelve su cierre original sin afectar a un sucesor vigente. |
| `transfer` | `publicationId`, `assetId` nuevo | Cierra el periodo indicado y crea su sucesor en una transacción. Exige que esté vigente y que cambie el activo. |
El reintento de un traslado devuelve el sucesor persistido si coinciden predecesor y nuevo activo, incluso si ese sucesor ya terminó o cambió de estado su activo. Un destino diferente para el mismo predecesor produce conflicto. Tras finalizar sin sucesor se utiliza `publish` para comenzar un nuevo periodo. El alta repetida es idempotente respecto de la publicación vigente, no una clave global de idempotencia para todas las altas históricas.
Las mutaciones bloquean la compañía antes de leer periodos y comprobar cardinalidad, compartiendo la serialización por compañía de las escrituras de inventario. Después bloquean el activo destinatario y leen/bloquean el recurso SMB a través de su lector público. El cierre se persiste antes que el sucesor, dentro de la misma transacción. Una excepción revierte ambas escrituras; la unicidad SQL es la garantía final y sus conflictos se traducen a `PUBLICATION_CONFLICT`.
No se permiten nuevas publicaciones ni traslados hacia activos no operativos o recursos inactivos (`ASSET_INACTIVE`, `SMB_RESOURCE_INACTIVE`). La desactivación concurrente queda serializada: o se registra antes de la publicación y esta se rechaza, o se registra después y conserva la publicación. Nunca se cierran periodos automáticamente por desactivación. Finalizar y recuperar un resultado idempotente siguen permitidos.
## Consultas y Typed Relationships
`NetworkSharePublicationQuery` es el segundo `RelationshipQueryService` real. La composición estática mantiene `assets:directory-membership`, añade la definición de publicación y registra `network:smb-share`; los proveedores siguen siendo `assets` y `resources`.
La definición es dirigida, `record`, `lifecycle`, `owner-command`, con múltiples destinos por activo y máximo un origen vigente por recurso. Las vistas incluyen `typeId`, referencias de origen y destino, `origin: { kind: "record", id: publicationId }` y estado `active` / `ended`. No tienen `roleCode` ni atributos arbitrarios.
- Consulta directa: ancla `assets:asset`, dirección `outgoing`.
- Consulta inversa: ancla `resources:resource`, dirección `incoming`.
- `includeEnded: false` selecciona solo publicaciones vigentes; `true` añade las finalizadas. La orientación de las referencias permanece activo → recurso.
- Anclas inexistentes, ajenas o no SMB se consideran no disponibles. El contexto procede de la autorización existente, sin aceptar contextos fabricados por consumidores.
La proyección compartida `readPublicationPage` filtra compañía, ancla y estado en SQL, ordena por UUID y consulta `LIMIT pageSize+1`, con `id > afterId`. Solo retiene el centinela adicional para detectar siguiente página. No hidrata agregados completos ni sus revisiones y no pagina un inventario en memoria. `pageSize` admite 1–100.
`SignedQueryCursor` reutiliza el codec HMAC del proveedor Directory mediante su adaptador existente. Cada instancia tiene clave efímera propia; el ámbito incluye compañía, operador y consulta normalizada (ancla, dirección, estado y tamaño). Publicaciones especializadas, publicaciones tipadas y Directory usan ámbitos/instancias separados: sus cursores son incompatibles. Cambiar filtros, compañía u operador invalida el cursor. El identificador de petición no forma parte del ámbito. Reiniciar o cambiar de instancia de servidor requiere comenzar la paginación de nuevo, igual que en el patrón previo; no se persisten claves nuevas ni datos de negocio en JSON.
Se reutiliza `AssetSummaryResolver`. Resources aporta `NetworkShareSummaryResolver`, exclusivo de proyecciones SMB: una consulta por lote de hasta 100 referencias, deduplicada y acotada por compañía. Conserva orden y repetidos y devuelve `unavailable` sin filtrar nombres para recursos inexistentes, ajenos, ordinarios o dominios Windows. Los resúmenes de recursos inactivos mantienen su estado real.
## HTTP y autorización
Rutas especializadas autenticadas:
- `POST /api/asset-network-share-publications/:companyId`: comando estricto con `action` y los campos de la tabla anterior. Respuesta `{ publication }`.
- `GET /api/asset-network-share-publications/:companyId`: colección con `assetId?`, `resourceId?`, `includeEnded=true|false` (por defecto false), `pageSize` (por defecto 50) y `cursor?`. Respuesta `{ publications, nextCursor }`.
- `GET /api/asset-network-share-publications/:companyId/relationships`: adaptador tipado acotado compartido con el paso 2.4. Parámetros `typeId`, `anchorType`, `anchorId`, `direction`, `includeEnded`, `pageSize` y `cursor?`; respuesta `{ page, summaries }`. Admite únicamente las dos definiciones registradas en este adaptador, sin explorador universal.
La ruta anterior `/api/asset-directory-memberships/:companyId/relationships` conserva el mismo adaptador y sus consultas Directory existentes. Las opciones de dominios y elegibilidad no cambian.
Para obtener el publicador vigente se filtra por `resourceId` con `includeEnded=false`; habrá cero o un elemento. Para el historial se usa el mismo filtro con `includeEnded=true`, siguiendo `nextCursor`. Para las carpetas de un equipo se filtra por `assetId`.
El actor se obtiene de la sesión autenticada, nunca de `operatorId` enviado por el navegador. Las escrituras usan el control global de origen y CSRF de Identity; compañía e identidades se validan en la frontera y en las consultas. Se mantiene la política actual de operadores autenticados de Metis Desk y el contexto de lectura autorizado ya existente, sin añadir un segundo sistema de permisos. Entrada inválida devuelve 400, ausencia funcional 404 y conflicto 409; la lectura tipada mantiene sus errores 400/403/404. Los errores inesperados siguen el mecanismo HTTP centralizado.
## AccessWorkspace y límites
Publicar, finalizar o trasladar no modifica Resource, AccessItem(resource), concesiones, necesidades, cobertura, WorkProfile ni FolderMapping. Tampoco asigna dominios, cuentas locales ni grupos. La integración automática del paso 3.2 permanece en Resources/Companies y no interviene en estos comandos.
No se añade UI. Los contratos, identificadores de periodo, lectura paginada, resúmenes y comandos dejan preparada la presentación del paso 3.5. Local Identities y ampliaciones de FolderMapping pertenecen al paso 3.4. No se documentan puertos de switch, disponibilidad técnica ni topología de almacenamiento.
## Validación
La suite `apps/server/src/network-share-publications.integration.test.ts` exige `NODE_ENV=test` y `DB_NAME=metis_desk_test`; genera y elimina exclusivamente sus compañías y actores de fixture. Cubre periodos, variantes de activo, cardinalidad SQL, compañía, borrado histórico, rollback por fallo de inserción, altas/traslados/cierres concurrentes, desactivación concurrente, cursores, SQL keyset, resolución por lote, accesos sin modificaciones y HTTP con sesión/origen/CSRF. Las pruebas de dominio viven en `modules/assets/src/api/domain/network-share-publication.test.ts`.
Comando específico: `pnpm --filter @metis/server test:network-share-publications`. Deben ejecutarse también las regresiones de SMB, accesos y dominios, las suites de Assets/Resources/relationship-contracts, los typechecks afectados, Biome, ESLint, build del servidor y `git diff --check`.
Fallos previos conocidos ajenos a esta implementación: dos expectativas de texto en Companies y TS2375 en `packages/shadcn-ui/src/components/calendar.tsx`. No se cambia vendor ni se ajustan esas pruebas.
### Resultado de la verificación del paso 3.3
- Typechecks de `@metis/assets`, `@metis/resources`, `@metis/relationship-contracts`, `@metis/server` y `@metis/web`: correctos (`pnpm --filter … typecheck`).
- Suites de Assets: 131/131; Resources: 80/80; contratos tipados: 13/13. Se ejecutaron los scripts con `--test-isolation=none` para comprobar todos los casos.
- Integración MariaDB de publicaciones, SMB, accesos y Directory: 45/45. Regresiones adicionales de inventario, infraestructura, sedes y propagación de plantillas: 4/4. La suite nueva de publicaciones (10 casos incluidos en los 45) también se repitió con esas regresiones.
- `biome check` sobre los 65 ficheros TypeScript/TSX/JSON modificados o nuevos del trabajo acumulado: correcto, sin avisos; `eslint` sobre los 58 ficheros TS/TSX: correcto.
- `pnpm --filter @metis/server build`, revisión del grafo de dependencias de producción de 13 paquetes y `git diff --check`: correctos, sin ciclos.
- Companies: 171/173, con las dos expectativas previas en `companies-page.test.tsx:180` y `:210`. Typecheck directo del paquete vendor: reproduce TS2375 en `calendar.tsx:165`; no se modifica.
Todas las integraciones se ejecutaron con `DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none --test-concurrency=1 …` desde `apps/server`, sin intervenir en datos de compañías reales.
## Identidades locales y cuenta del mapeo (paso 3.4)
La clasificación local de entornos y la cuenta documental de cada FolderMapping se describen en [identidades locales y cuentas de carpetas](identidades-locales-y-cuentas-de-carpetas.md). La publicación sigue sin determinar quién accede a la carpeta ni qué cuenta utiliza un perfil.

View File

@ -0,0 +1,312 @@
# Recursos compartidos SMB — pasos 3.1 y 3.2
## Identidad y alcance
Una carpeta SMB es un Resource existente especializado, con el mismo UUID. Se reutilizan
`template:shared_folder`, `field:unc`, `field:server` y `field:description`. El nombre visible
es independiente de la UNC. La capacidad del recurso es `network:smb-share` y solo se
expone cuando existe una proyección válida; la clasificación de plantilla por stockKey
identifica el tipo esperado, no convierte un recurso antiguo en SMB.
No se incorporan publicadores, relaciones Asset–Resource, cuentas nuevas, resolutores
de relaciones ni modificaciones de WorkProfile o FolderMapping. Los accesos reutilizan
el modelo de permisos, necesidades y concesiones existente.
## Dirección y normalización
`field:unc` es la única entrada editable. La presentación conserva mayúsculas, Unicode
y un separador final introducido por el usuario, después del trim exterior ya utilizado
por Resource. La clave de comparación elimina un único separador final y pliega solo
ASCII A–Z. No aplica NFC/NFKC, eliminación de tildes ni case folding Unicode.
Se admiten hosts NetBIOS, DNS e IPv4 como nombres documentales, comparticiones
administrativas como C$, Unicode y espacios interiores en nombres de carpetas.
No se resuelve DNS ni se identifica el publicador por el host o la IP.
Se rechazan rutas locales, URL, host/compartición vacíos, separadores repetidos,
segmentos `.` y `..`, caracteres de control, Unicode mal formado, nombres de segmento
con espacios exteriores o punto final, caracteres estructuralmente incompatibles,
UNC extendidas de dispositivo y direcciones con más de 255 unidades UTF-16.
IPv6 con dos puntos, URI SMB y hosts terminados en punto quedan fuera de v1.
La comparación conservadora considera equivalentes `\\NAS\Docs` y `\\nas\docs`, pero
no equipara `\\nas\Docs` con `\\files.empresa.local\Docs`, ni formas Unicode distintas.
Las variantes Unicode no ASCII pueden permanecer distintas aunque un servidor concreto
las trate como equivalentes: no inventamos una política de cotejo universal.
### Raíz y subcarpetas
Se permiten rutas profundas por compatibilidad con el modelo UNC existente de Companies.
El contrato distingue `host`, `shareName` y `relativePath`; una ruta relativa vacía indica
la raíz de la compartición. Se recomienda registrar la raíz cuando se inventaría una
compartición y utilizar rutas más profundas en FolderMapping, pero no se invalidan
registros por contener subcarpetas. La unicidad corresponde a la dirección completa;
una raíz y una subcarpeta son direcciones distintas.
## Persistencia e integridad
La migración idempotente `migrateNetworkShares` crea `resource_network_shares`, sin
rellenar registros anteriores. Se ejecuta después de las migraciones de Resources y
Dominios, cuyos índices compuestos de raíz y plantilla reutiliza.
```sql
resourceId CHAR(36) BINARY PRIMARY KEY
companyId CHAR(36) BINARY NOT NULL
templateId CHAR(36) BINARY NOT NULL
templateKey VARCHAR(100) NOT NULL -- fijo: template:shared_folder
protocol VARCHAR(10) NOT NULL -- fijo: smb
normalizedAddress VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
activeAddress VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL
UNIQUE (companyId, activeAddress)
UNIQUE (resourceId, companyId)
FOREIGN KEY (resourceId, companyId, templateId)
REFERENCES resources(id, companyId, templateId)
FOREIGN KEY (templateId, templateKey)
REFERENCES resource_definitions(id, stockKey)
```
CHECK restringe protocolo, plantilla, dirección no vacía y coincidencia binaria entre
clave activa y normalizada cuando no es null. El código mantiene `activeAddress` igual a
la dirección canónica para activos y null para inactivos. MariaDB garantiza la unicidad
de las claves activas, incluso bajo concurrencia; no se depende de un SELECT previo.
El cotejo binario conserva diferencias Unicode y acentos.
La proyección no es otra entidad principal ni otro editor. No duplica dirección de
presentación, host o shareName: se obtienen del campo UNC validado de Resource.
## Transacciones, historial y ciclo de vida
El guardado existente valida nuevas carpetas y carpetas ya especializadas. Dentro de
una transacción escribe Resource, revisiones y proyección SMB. Un conflicto de índice se
traduce a `SMB_ADDRESS_EXISTS` y provoca rollback de todas esas escrituras, también de
la proyección access si existía. Una UNC inválida produce `INVALID_SMB_ADDRESS`.
Cambiar UNC o nombre conserva UUID y revisiones. Un guardado idéntico no añade revisión.
Modificar solo notas/nombre/presentación no actualiza innecesariamente la proyección si
la dirección canónica y el estado no cambian.
Desactivar conserva Resource, proyección e historial y libera la clave activa.
Reactivar vuelve a adquirirla; un conflicto mantiene el recurso inactivo sin una revisión
parcial. Las direcciones inactivas se pueden reutilizar.
Eliminar utiliza las protecciones anteriores y comprueba también referencias a la
proyección SMB. Si existen referencias actuales o históricas externas, devuelve
`RESOURCE_IN_USE`. Sin referencias, elimina los datos propios, incluida la proyección,
en la transacción existente. No conserva un historial separado tras un borrado físico
explícito: se mantiene la semántica anterior de Resource.
## Catálogo
Renombrar plantilla/campos, cambiar etiquetas, iconos, secciones u orden no elimina la
capacidad. `template:shared_folder` debe conservar el campo de serie `field:unc`; quitarlo
produce `SMB_FIELD_REQUIRED`. El mecanismo anterior ya impide cambiar el tipo del campo.
No se modifica automáticamente la obligatoriedad del catálogo: el validador especializado
exige UNC para altas SMB aunque la característica siga figurando como opcional.
Deshabilitar disponibilidad por compañía impide altas nuevas, sin desespecializar los
registros existentes. No existe una operación ordinaria de borrado físico de plantillas;
las FK protegen la plantilla de los recursos especializados. La sincronización de
especificaciones conserva las características y añade revisiones mediante el mecanismo
existente; no altera la dirección ni la proyección por cambios de presentación.
## Registros anteriores y diagnóstico
Los registros previos **no se convierten automáticamente**, incluso si su UNC es válida,
al reiniciar, editar, activar/desactivar o sincronizar la plantilla. La distinción se
basa en la presencia de la proyección persistida, no en fechas aproximadas.
`GET /api/resources/:companyId/network-share-diagnostics`, con la autenticación existente,
no acepta parámetros adicionales y devuelve solo identificadores y clasificación:
- `classification`: `shared-folder` cierta por stockKey, o `legacy-candidate` heurística.
- `addressStatus`: `valid`, `invalid` o `missing`.
- `duplicate`: dirección canónica compartida por candidatos activos de esa compañía.
- `managed`: ya tiene proyección SMB.
- `requiresConversion`: todavía no tiene esa proyección.
La heurística para `template:access` busca una referencia o característica con prefijo
UNC. No usa nombres visibles, no declara capacidad y puede necesitar revisión humana.
No descubre carpetas escondidas en cualquier texto libre ni interpreta nombres de tipos
de acceso. El diagnóstico informa duplicados también entre candidatos, pero la restricción
SQL solo se aplica a recursos especializados; resolver legados queda fuera de 3.1 y 3.2.
El diagnóstico usa el inventario autorizado ya existente, no escribe ni incluye notas,
credenciales o características completas. No modifica plantilla, integración ni historial,
y no fusiona, elimina o sustituye recursos.
## Contratos públicos y siguientes fases
ResourceDTO incluye `networkShare` opcional con capacidad, protocolo, dirección principal,
clave canónica, host, nombre compartido y ruta relativa. Es solo respuesta, no entrada.
`@metis/resources/services` exporta `INetworkShareReader` y `NetworkShareReference`.
`resources.services.networkShares(transaction).find(companyId, resourceId)` devuelve
Maybe de la referencia mínima con UUID, compañía y estado; comprueba la especialización
y la dirección persistida y bloquea la lectura para los futuros comandos de publicación.
El consumidor debe autenticar y autorizar el comando antes de usar el lector interno.
No expone repositorios ni introduce dependencias de Resources hacia Assets o Companies.
La integración access sigue teniendo contexto fijo. Una carpeta histórica sin integración
no puede incorporarla mediante edición ordinaria. Las nuevas carpetas reciben integración
automática según el paso 3.2 descrito más abajo; no se incorpora conversión histórica ni
se crean cuentas o una segunda ficha editable.
`field:server` sigue siendo documental y no se resuelve contra Assets.
Una modificación de UNC en un recurso integrado actualiza su proyección access mediante
el flujo existente. No reescribe UNC, letra ni historial de FolderMapping; una ruta vieja
sigue siendo detectable por las reglas de disponibilidad anteriores. `permissionsCovered`
no cambia y continúa siendo cobertura documental, no comprobación real de permisos.
## Validación
- Resources: normalizador, equivalencias/no equivalencias, catálogo y diagnóstico.
- MariaDB: alta, edición, rollback, estados, concurrencia, FK, unicidad, lector, borrado,
legado, access, dominios, recursos ordinarios, FolderMapping y contrato HTTP autenticado.
- Integración dedicada: `pnpm --filter @metis/server test:network-shares`.
- La prueba exige NODE_ENV=test y DB_NAME=metis_desk_test antes de conectar; limpia solo
UUID de fixture y tablas auxiliares de referencias creadas por esa ejecución.
Las incidencias previas de dos textos de Companies y TS2375 en el calendario vendor
permanecen fuera del alcance. Los resultados de validación de esta entrega se informan
al finalizar, sin atribuir a esta fase pruebas que no se hayan ejecutado.
### Resultados del paso 3.1
- Suite completa de Resources: 78/78.
- Integración combinada Dominios 2.1–2.4 y SMB 3.1: 27/27 casos nativos, solo
metis_desk_test. Incluye 11 subcasos SMB y su contenedor.
- Typecheck de Resources, Assets, Companies, servidor y web: correcto.
- Biome y ESLint de los archivos afectados: correctos; build del servidor y
`git diff --check`: correctos.
- Suite completa Companies: 171/173; se reproducen únicamente los dos fallos previos
de texto en companies-page.test.tsx. No se modifican esas pruebas.
- Typecheck directo de vendor: reproduce TS2375 en calendar.tsx:165. No se modifica
packages/shadcn-ui ni se oculta el error.
Comandos utilizados (PNPM requiere desactivar su verificación automática de dependencias
antes de ejecutar en este entorno):
```bash
pnpm --config.verify-deps-before-run=false --filter @metis/resources --filter @metis/assets --filter @metis/companies --filter @metis/server --filter @metis/web typecheck
pnpm --config.verify-deps-before-run=false --filter @metis/server build
pnpm --config.verify-deps-before-run=false --filter @metis/server test:network-shares
```
Las suites completas de Resources y Companies se ejecutaron con todos los archivos de
sus scripts existentes, añadiendo `--test-isolation=none` para obtener los casos nativos.
La integración combinada se ejecutó con ese mismo flag y `--test-concurrency=1` sobre
los cinco archivos directory-domains, asset-directory-memberships, typed-relationships,
windows-domains-ui y network-shares. Biome y ESLint se limitaron a los archivos afectados.
## Paso 3.2 — integración automática con AccessWorkspace
Esta sección actualiza el comportamiento de las **nuevas altas** descrito para 3.1.
El paso 3.2 no incorpora conversión, adopción ni migración de recursos anteriores.
Cualquier adopción histórica requerirá un encargo independiente.
### Alta y contexto
El servidor establece `integration.kind=access`, referencia `field:unc` y fija
`systemId=null` y `environmentId=null`. No deduce autenticación del host UNC, dominio
ni publicador. Un cliente que suministra buzón, otro campo/tipo o un contexto incompatible
recibe `SMB_ACCESS_INCOMPATIBLE`; omitir integración activa la configuración automática.
El tipo configurable de AccessWorkspace se obtiene por el UUID ya utilizado por otra
carpeta de esa plantilla en la compañía. Así, renombrar el tipo no cambia las referencias.
Para la primera carpeta, Companies reutiliza un tipo visible llamado «Carpeta compartida
SMB» o crea ese tipo local usando el agregado y su auditoría habituales. Esta etiqueta
solo selecciona un valor por defecto de catálogo: la capacidad SMB sigue dependiendo de
stockKey y de la especialización, nunca del nombre. No se crean permisos, cuentas ni grupos.
Un tipo existente inactivo no se reactiva silenciosamente.
No se añade una migración de esquema ni una clasificación paralela. El tipo se conserva
por sus referencias UUID existentes; después de eliminar todas las carpetas, una primera
alta vuelve a aplicar la selección de catálogo descrita.
### Puerto transaccional y ownership
`IResourceAccessWriter`, exportado por `@metis/resources/services`, dispone de:
- `ensureSmbType`: prepara el tipo de acceso para una nueva carpeta dentro de la transacción.
- `project`: recibe valores e identidades de Resource y sus revisiones, sin exponer su
repositorio ni acceder al agregado de Companies desde Resources.
Companies implementa el puerto con `ResourceAccessWriter`, reutiliza `writeAccessRow` y
conserva el UUID de Resource y los UUID, actor, fecha y orden de sus revisiones. La
composición inyecta `companies.resourceAccess(transaction)` en Resources siguiendo
el patrón de DirectoryContextWriter. Resources no importa Companies: no aparece un ciclo.
La escritura de proyecciones access se ha trasladado de resource-compatibility a su módulo
propietario. Se conservan las consultas de referencias y las protecciones de eliminación
existentes, sin refactorizar globalmente AccessWorkspace.
### Flujo atómico
1. Bloquear catálogo de Resources, catálogo de accesos y compañía en el orden compartido.
2. Solo para una nueva carpeta, preparar el tipo configurable dentro de la transacción.
3. Validar datos y establecer integración en el servidor.
4. Escribir Resource y sus revisiones.
5. Escribir la proyección SMB y comprobar unicidad.
6. Proyectar valores y revisiones en AccessWorkspace.
7. Confirmar; cualquier fallo revierte también el tipo creado y ambas proyecciones.
`ACCESS_PROJECTION_UNAVAILABLE` indica que no se puede preparar el acceso; no se guarda
una nueva carpeta a medias si falta el participante de Companies. Las composiciones
que escriban recursos con integración access deben inyectar ese participante.
Renombrar, editar características/UNC y activar/desactivar reutiliza la misma proyección.
No se cierran necesidades, concesiones ni configuraciones al desactivar. Las referencias
actuales o históricas de acceso siguen impidiendo el borrado físico de Resource.
### Interfaz y utilización
El formulario existente muestra ayuda traducida sobre acceso automático, configurado o
histórico. No ofrece selectores incompatibles de sistema, entorno o tipo para SMB.
El navegador sigue editando únicamente Resource; los endpoints antiguos de escritura
AccessItem(resource) continúan rechazados por `RESOURCE_MOVED`.
Cuentas y accesos permite crear permisos configurables, necesidades y concesiones para
la carpeta, con las reglas existentes. Una misma carpeta puede recibir una concesión
a un grupo AD y otra a una cuenta local NAS. La cobertura es documental: no autentica,
comprueba ACL ni demuestra permisos efectivos SMB/NTFS.
WorkProfile y FolderMapping conservan su implementación, referencias, UNC registrada,
letra de unidad, historial y cálculo de cobertura. El cambio de dirección del recurso
no reescribe mapeos anteriores: sigue siendo detectable como desactualización.
### Históricos y límites
El alta automática se decide por una operación nueva, no por la presencia de una UNC ni
por una fecha. Leer, reiniciar, editar o activar una carpeta antigua sin integración
no añade AccessItem ni cambia su contexto. También se conservan carpetas especializadas
en 3.1 sin integración, y recursos anteriores bajo template:access.
No hay publicadores (3.3), identidades locales vinculadas a activos ni cambios de
FolderMapping (3.4), pantallas de administración de carpetas (3.5), LDAP, credenciales,
grupos anidados ni permisos efectivos. No se amplía el diagnóstico histórico.
Prueba dedicada: `pnpm --filter @metis/server test:smb-access`, exclusivamente contra
metis_desk_test. Comprueba integración, rechazos, sincronización, tipo renombrado, concesiones
AD/NAS, cobertura, borrado, rollback tras escribir accesos y ausencia de adopción histórica.
### Verificación del paso 3.2
- Resources completo: 80/80, incluidos contrato automático, rechazo de contexto y ayuda es/en.
- MariaDB: 35/35 en la ejecución combinada de Dominios 2.1–2.4, SMB 3.1 y SMB Access 3.2.
Solo metis_desk_test; incluye concesiones directas y por grupo, cobertura, rollback real
después de escribir proyecciones, contexto renombrado y registros anteriores sin adopción.
- Typechecks de Resources, Assets, Companies, servidor y web: correctos.
- Biome, ESLint de los archivos afectados, build del servidor y git diff --check: correctos.
- Grafo de los 13 paquetes del workspace: sin ciclos.
- Companies completo: 171/173; permanecen las dos expectativas previas de texto en
companies-page.test.tsx. Las pruebas de accesos incluidas en esa suite pasan.
- Se reproduce TS2375 en packages/shadcn-ui/src/components/calendar.tsx:165; vendor intacto.
La integración combinada usa NODE_ENV=test, DB_NAME=metis_desk_test,
--test-isolation=none y --test-concurrency=1. Las comprobaciones completas de paquetes
mantienen todos los archivos de sus scripts y añaden --test-isolation=none para obtener
los casos del runner nativo. Este paso no añade migraciones, conversiones históricas ni
pruebas de interacción en navegador; el cambio mínimo del formulario se verifica mediante SSR.
## Publicador documental (paso 3.3)
La relación especializada, sus comandos e historial, las restricciones SQL y las consultas tipadas se describen en [publicación de recursos compartidos SMB](publicacion-de-recursos-compartidos-smb.md). Assets es su propietario y consume el lector SMB público de Resources; esta relación no modifica la integración de accesos descrita en este documento.

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",

View File

@ -0,0 +1,128 @@
import type { NetworkShareReference } from "@metis/resources/services";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { NetworkSharePublicationCommand } from "../../common/network-share-publications";
import { AssetCatalogError } from "../domain/asset-definition";
import { NetworkSharePublication } from "../domain/network-share-publication.aggregate";
import type { PublicationChange } from "./network-share-publication.repository";
export type PublicationContext = Readonly<{
publications: readonly NetworkSharePublication[];
share: Maybe<NetworkShareReference>;
asset: { id: UniqueID; operational: boolean } | undefined;
}>;
type Audit = { companyId: UniqueID; actorId: UniqueID; now: string };
type ChangeResult = Result<PublicationChange, AssetCatalogError>;
function endPublication(previous: NetworkSharePublication, audit: Audit): ChangeResult {
const ended = previous.end(audit.actorId, audit.now);
return ended.isSuccess ? Result.ok({ publication: ended.data }) : ended;
}
function createPublication(
context: PublicationContext,
resourceId: string,
audit: Audit,
previous?: NetworkSharePublication
): ChangeResult {
const share = context.share.getOrUndefined();
if (
!(share && share.companyId.equals(audit.companyId)) ||
share.capability !== "network:smb-share" ||
share.id.toPrimitive() !== resourceId
) {
return Result.fail(new AssetCatalogError("SMB_RESOURCE_NOT_FOUND"));
}
if (!context.asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
if (!context.asset.operational) {
return Result.fail(new AssetCatalogError("ASSET_INACTIVE"));
}
if (!share.active) {
return Result.fail(new AssetCatalogError("SMB_RESOURCE_INACTIVE"));
}
const created = NetworkSharePublication.create({
companyId: audit.companyId,
assetId: context.asset.id,
resourceId: share.id,
predecessorId: previous ? Maybe.some(previous.id) : Maybe.none(),
startedAt: audit.now,
startedBy: audit.actorId,
});
if (!created.isSuccess) {
return created;
}
if (!previous) {
return Result.ok({ publication: created.data });
}
const ended = previous.end(audit.actorId, audit.now);
return ended.isSuccess ? Result.ok({ publication: created.data, ended: ended.data }) : ended;
}
function transferPublication(
context: PublicationContext,
command: Extract<NetworkSharePublicationCommand, { action: "transfer" }>,
audit: Audit
): ChangeResult {
const previous = context.publications.find(
(item) => item.id.toPrimitive() === command.publicationId
);
if (!previous) {
return Result.fail(new AssetCatalogError("PUBLICATION_NOT_FOUND"));
}
const replay = context.publications.find((item) =>
item.predecessorId.getOrUndefined()?.equals(previous.id)
);
if (replay) {
return replay.assetId.toPrimitive() === command.assetId
? Result.ok({ publication: replay })
: Result.fail(new AssetCatalogError("PUBLICATION_CONFLICT"));
}
if (!previous.active || previous.assetId.toPrimitive() === command.assetId) {
return Result.fail(new AssetCatalogError("PUBLICATION_CONFLICT"));
}
return createPublication(context, previous.resourceId.toPrimitive(), audit, previous);
}
export function planNetworkSharePublication(
context: PublicationContext,
command: NetworkSharePublicationCommand,
audit: Audit
): ChangeResult {
switch (command.action) {
case "end": {
const previous = context.publications.find(
(item) => item.id.toPrimitive() === command.publicationId
);
return previous
? endPublication(previous, audit)
: Result.fail(new AssetCatalogError("PUBLICATION_NOT_FOUND"));
}
case "transfer":
return transferPublication(context, command, audit);
case "publish": {
const current = context.publications.find((item) => item.active);
if (current) {
return current.assetId.toPrimitive() === command.assetId
? Result.ok({ publication: current })
: Result.fail(new AssetCatalogError("PUBLICATION_CONFLICT"));
}
return createPublication(context, command.resourceId, audit);
}
}
}

View File

@ -0,0 +1,35 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type {
NetworkSharePublicationCommand,
NetworkSharePublicationDTO,
NetworkSharePublicationQuery,
} from "../../common/network-share-publications";
import type { AssetCatalogError } from "../domain/asset-definition";
import type { NetworkSharePublication } from "../domain/network-share-publication.aggregate";
import type { PublicationContext } from "./network-share-publication-plan";
export type PublicationChange = {
publication: NetworkSharePublication;
ended?: NetworkSharePublication;
};
export interface INetworkSharePublicationRepository {
mutate(
companyId: UniqueID,
command: NetworkSharePublicationCommand,
action: (context: PublicationContext) => Result<PublicationChange, AssetCatalogError>
): Promise<Result<NetworkSharePublication, AssetCatalogError>>;
list(
companyId: UniqueID,
actorId: UniqueID,
query: NetworkSharePublicationQuery
): Promise<
Result<
{ publications: NetworkSharePublicationDTO[]; nextCursor: string | null },
AssetCatalogError
>
>;
}

View File

@ -0,0 +1,45 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
NetworkSharePublicationCommandSchema,
NetworkSharePublicationQuerySchema,
} from "../../common/network-share-publications";
import { AssetCatalogError } from "../domain/asset-definition";
import type { INetworkSharePublicationRepository } from "./network-share-publication.repository";
import { planNetworkSharePublication } from "./network-share-publication-plan";
export class NetworkSharePublicationUseCases {
constructor(private readonly repository: INetworkSharePublicationRepository) {}
execute(companyId: string, input: unknown, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const command = NetworkSharePublicationCommandSchema.safeParse(input);
if (!(company.isSuccess && actor.isSuccess && command.success)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.mutate(company.data, command.data, (context) =>
planNetworkSharePublication(context, command.data, {
companyId: company.data,
actorId: actor.data,
now: new Date().toISOString(),
})
);
}
list(companyId: string, input: unknown, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const query = NetworkSharePublicationQuerySchema.safeParse(input);
if (!(company.isSuccess && actor.isSuccess && query.success)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.list(company.data, actor.data, query.data);
}
}

View File

@ -0,0 +1,90 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { AggregateRoot } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { AssetCatalogError } from "./asset-definition";
type Props = Readonly<{
companyId: UniqueID;
assetId: UniqueID;
resourceId: UniqueID;
predecessorId: Maybe<UniqueID>;
startedAt: string;
startedBy: UniqueID;
endedAt: Maybe<string>;
endedBy: Maybe<UniqueID>;
}>;
/** Periodo documental de publicación; no representa almacenamiento ni disponibilidad técnica. */
export class NetworkSharePublication extends AggregateRoot<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(
input: Omit<Props, "endedAt" | "endedBy">
): Result<NetworkSharePublication, AssetCatalogError> {
if (!z.iso.datetime().safeParse(input.startedAt).success) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return Result.ok(
new NetworkSharePublication({ ...input, endedAt: Maybe.none(), endedBy: Maybe.none() })
);
}
static rehydrate(props: Props, id: UniqueID) {
return new NetworkSharePublication(props, id);
}
get companyId() {
return this.props.companyId;
}
get assetId() {
return this.props.assetId;
}
get resourceId() {
return this.props.resourceId;
}
get predecessorId() {
return this.props.predecessorId;
}
get active() {
return !this.props.endedAt.getOrUndefined();
}
end(actor: UniqueID, now: string): Result<NetworkSharePublication, AssetCatalogError> {
if (!this.active) {
return Result.ok(this);
}
if (!z.iso.datetime().safeParse(now).success || now < this.props.startedAt) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return Result.ok(
new NetworkSharePublication(
{ ...this.props, endedAt: Maybe.some(now), endedBy: Maybe.some(actor) },
this.id
)
);
}
toPrimitive() {
return {
id: this.id.toPrimitive(),
companyId: this.companyId.toPrimitive(),
assetId: this.assetId.toPrimitive(),
resourceId: this.resourceId.toPrimitive(),
predecessorId: this.predecessorId.getOrUndefined()?.toPrimitive() ?? null,
startedAt: this.props.startedAt,
startedBy: this.props.startedBy.toPrimitive(),
endedAt: this.props.endedAt.getOrUndefined() ?? null,
endedBy: this.props.endedBy.getOrUndefined()?.toPrimitive() ?? null,
};
}
}

View File

@ -0,0 +1,213 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { RelationshipPageSchema, createRelationshipRegistry } from "@metis/relationship-contracts";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import {
NETWORK_SHARE_PUBLICATION_DEFINITION,
NETWORK_SHARE_PUBLICATION_TYPE_ID,
} from "../../common/network-share-publications";
import type { PublicationContext } from "../application/network-share-publication-plan";
import { planNetworkSharePublication } from "../application/network-share-publication-plan";
import { NetworkSharePublicationMapper } from "../infrastructure/persistence/network-share-publication.mapper";
import { NetworkSharePublication } from "./network-share-publication.aggregate";
function fixture() {
const companyId = UniqueID.generateNewID();
const actorId = UniqueID.generateNewID();
const assetId = UniqueID.generateNewID();
const resourceId = UniqueID.generateNewID();
const context: PublicationContext = {
publications: [],
asset: { id: assetId, operational: true },
share: Maybe.some({
id: resourceId,
companyId,
active: true,
capability: "network:smb-share",
protocol: "smb",
address: String.raw`\\alias\Docs`,
normalizedAddress: String.raw`\\alias\docs`,
host: "alias",
shareName: "Docs",
relativePath: "",
}),
};
const audit = { companyId, actorId, now: "2026-10-20T12:00:00.000Z" };
const command = {
action: "publish",
assetId: assetId.toPrimitive(),
resourceId: resourceId.toPrimitive(),
} as const;
return { context, audit, command };
}
test("publicación: alta, periodo, cierre idempotente e identidad persistida", () => {
const f = fixture();
const result = planNetworkSharePublication(f.context, f.command, f.audit);
assert.ok(result.isSuccess);
const item = result.data.publication;
assert.equal(item.id.toPrimitive()[14], "7");
assert.ok(item.active);
const ended = item.end(f.audit.actorId, "2026-10-20T13:00:00.000Z");
assert.ok(ended.isSuccess);
assert.equal(ended.data.id.toPrimitive(), item.id.toPrimitive());
assert.equal(ended.data.active, false);
const repeated = ended.data.end(f.audit.actorId, "incorrecto");
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, ended.data);
assert.equal(item.end(f.audit.actorId, "2026-10-20T11:00:00.000Z").isSuccess, false);
const mapper = new NetworkSharePublicationMapper();
const { activeResourceId: _key, ...row } = mapper.toPersistence(ended.data);
assert.deepEqual(mapper.toDomain(row).toPrimitive(), row);
});
test("publicación: cardinalidad y reintento idéntico no crean periodos", () => {
const f = fixture();
const initial = planNetworkSharePublication(f.context, f.command, f.audit);
assert.ok(initial.isSuccess);
const context = { ...f.context, publications: [initial.data.publication] };
const repeated = planNetworkSharePublication(context, f.command, f.audit);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data.publication, initial.data.publication);
assert.equal(
planNetworkSharePublication(
context,
{ ...f.command, assetId: UniqueID.generateNewID().toPrimitive() },
f.audit
).isSuccess,
false
);
});
test("publicación: traslado explícito y replay estable incluso tras finalizar sucesor", () => {
const f = fixture();
const initial = planNetworkSharePublication(f.context, f.command, f.audit);
assert.ok(initial.isSuccess);
const newAsset = UniqueID.generateNewID();
const command = {
action: "transfer",
publicationId: initial.data.publication.id.toPrimitive(),
assetId: newAsset.toPrimitive(),
} as const;
const transferred = planNetworkSharePublication(
{
...f.context,
publications: [initial.data.publication],
asset: { id: newAsset, operational: true },
},
command,
f.audit
);
assert.ok(transferred.isSuccess && "ended" in transferred.data);
assert.ok(transferred.data.ended);
assert.equal(transferred.data.ended.active, false);
assert.ok(
transferred.data.publication.predecessorId.getOrUndefined()?.equals(initial.data.publication.id)
);
const ended = transferred.data.publication.end(f.audit.actorId, f.audit.now);
assert.ok(ended.isSuccess);
const replay = planNetworkSharePublication(
{ ...f.context, asset: undefined, publications: [transferred.data.ended, ended.data] },
command,
f.audit
);
assert.ok(replay.isSuccess);
assert.equal(replay.data.publication, ended.data);
});
test("publicación: estados, recurso no SMB, identidad y fechas inválidas", () => {
const f = fixture();
assert.equal(
planNetworkSharePublication(
{
...f.context,
asset: { id: f.context.asset?.id ?? UniqueID.generateNewID(), operational: false },
},
f.command,
f.audit
).isSuccess,
false
);
assert.equal(
planNetworkSharePublication({ ...f.context, share: Maybe.none() }, f.command, f.audit)
.isSuccess,
false
);
const share = f.context.share.getOrUndefined();
assert.ok(share);
assert.equal(
planNetworkSharePublication(
{ ...f.context, share: Maybe.some({ ...share, active: false }) },
f.command,
f.audit
).isSuccess,
false
);
assert.equal(
NetworkSharePublication.create({
companyId: f.audit.companyId,
assetId: share.id,
resourceId: share.id,
startedBy: f.audit.actorId,
predecessorId: Maybe.none(),
startedAt: "incorrecto",
}).isSuccess,
false
);
});
test("publicación: definición compatible con contratos comunes sin rol ni proveedor network", () => {
const registry = createRelationshipRegistry(
["assets", "resources"],
[NETWORK_SHARE_PUBLICATION_DEFINITION],
["network:smb-share"]
);
assert.ok(registry.isSuccess);
assert.deepEqual(NETWORK_SHARE_PUBLICATION_DEFINITION.activeCardinality, {
maxTargetsPerSource: null,
maxSourcesPerTarget: 1,
});
assert.equal(NETWORK_SHARE_PUBLICATION_DEFINITION.history, "lifecycle");
const f = fixture();
const result = planNetworkSharePublication(f.context, f.command, f.audit);
assert.ok(result.isSuccess);
const view = {
typeId: NETWORK_SHARE_PUBLICATION_TYPE_ID,
source: {
providerId: "assets",
entityType: "assets:asset",
id: f.command.assetId,
companyId: f.audit.companyId.toPrimitive(),
},
target: {
providerId: "resources",
entityType: "resources:resource",
id: f.command.resourceId,
companyId: f.audit.companyId.toPrimitive(),
},
origin: { kind: "record", id: result.data.publication.id.toPrimitive() },
stateCode: "active",
};
assert.ok(RelationshipPageSchema.safeParse({ items: [view], nextCursor: null }).success);
});

View File

@ -0,0 +1,78 @@
import type { Application, Request, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import type { NetworkSharePublicationUseCases } from "../../application/network-share-publication.use-cases";
const ScopeSchema = z.strictObject({ companyId: z.uuid() });
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") });
}
function scope(request: Request) {
return z.object({ companyId: z.uuid(), userId: z.uuid() }).safeParse({
...request.params,
userId: z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined).data?.userId,
});
}
export function registerNetworkSharePublicationRoutes(
app: Application,
service: NetworkSharePublicationUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.post("/:companyId", (request, response, next) => {
const context = scope(request);
if (
!(
context.success &&
ScopeSchema.safeParse(request.params).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
service
.execute(context.data.companyId, request.body, context.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ publication: result.data.toPrimitive() });
})
.catch(next);
});
router.get("/:companyId", (request, response, next) => {
const context = scope(request);
if (!(context.success && ScopeSchema.safeParse(request.params).success)) {
fail(response, "INVALID_REQUEST");
return;
}
service
.list(context.data.companyId, request.query, context.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(result.data);
})
.catch(next);
});
app.use("/api/asset-network-share-publications", authenticate, router);
}

View File

@ -1,13 +1,9 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type { RelationshipContext, RelationshipQuery } from "@metis/relationship-contracts";
import { z } from "zod/v4";
const CursorSchema = z.strictObject({ afterId: z.uuid(), scope: z.string() });
import { SignedQueryCursor } from "./signed-query-cursor";
/** Clave efímera por proveedor: un reinicio invalida el cursor, nunca la identidad persistida. */
export class DirectoryRelationshipCursor {
private readonly key = randomBytes(32);
private readonly codec = new SignedQueryCursor();
private scope(query: RelationshipQuery, context: RelationshipContext): string {
return JSON.stringify([
@ -24,50 +20,11 @@ export class DirectoryRelationshipCursor {
]);
}
encode(afterId: string, query: RelationshipQuery, context: RelationshipContext): string {
const payload = Buffer.from(
JSON.stringify({ afterId, scope: this.scope(query, context) })
).toString("base64url");
const signature = createHmac("sha256", this.key).update(payload).digest("base64url");
return `${payload}.${signature}`;
encode(afterId: string, query: RelationshipQuery, context: RelationshipContext) {
return this.codec.encode(afterId, this.scope(query, context));
}
decode(
cursor: string,
query: RelationshipQuery,
context: RelationshipContext
): string | undefined {
const [payload, signature, extra] = cursor.split(".");
if (
!(payload && signature) ||
extra !== undefined ||
!/^[A-Za-z0-9_-]+$/.test(payload) ||
!/^[A-Za-z0-9_-]{43}$/.test(signature)
) {
return undefined;
}
const expected = Buffer.from(
createHmac("sha256", this.key).update(payload).digest("base64url"),
"ascii"
);
const actual = Buffer.from(signature, "ascii");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return undefined;
}
try {
const decoded = CursorSchema.safeParse(
JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
);
return decoded.success && decoded.data.scope === this.scope(query, context)
? decoded.data.afterId
: undefined;
} catch {
// Un token firmado pero ilegible sigue siendo una entrada inválida, no un fallo de SQL.
return undefined;
}
decode(cursor: string, query: RelationshipQuery, context: RelationshipContext) {
return this.codec.decode(cursor, this.scope(query, context));
}
}

View File

@ -0,0 +1,31 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
/** Lectura mínima dentro de la transacción de Companies, sin exponer repositorios ni el ORM. */
export class LocalIdentityAssetReader {
constructor(
private readonly database: Sequelize,
private readonly transaction: Transaction
) {}
async find(company: UniqueID, asset: UniqueID) {
const rows = z.array(z.object({ status: z.string() })).parse(
await this.database.query(
"SELECT status FROM assets WHERE companyId=:companyId AND id=:assetId FOR UPDATE",
{
replacements: { companyId: company.toPrimitive(), assetId: asset.toPrimitive() },
transaction: this.transaction,
type: QueryTypes.SELECT,
}
)
);
const row = rows[0];
return row
? Maybe.some({ operational: ["operational", "available", "in_use"].includes(row.status) })
: Maybe.none<{ operational: boolean }>();
}
}

View File

@ -0,0 +1,29 @@
import type { Sequelize } from "sequelize";
export async function migrateNetworkSharePublications(database: Sequelize) {
await database.query(`CREATE TABLE IF NOT EXISTS asset_network_share_publications (
id CHAR(36) BINARY NOT NULL PRIMARY KEY,
companyId CHAR(36) BINARY NOT NULL,
assetId CHAR(36) BINARY NOT NULL,
resourceId CHAR(36) BINARY NOT NULL,
predecessorId CHAR(36) BINARY NULL,
startedAt VARCHAR(30) NOT NULL,
startedBy CHAR(36) BINARY NOT NULL,
endedAt VARCHAR(30) NULL,
endedBy CHAR(36) BINARY NULL,
activeResourceId CHAR(36) BINARY NULL,
UNIQUE KEY publication_active_resource (activeResourceId),
UNIQUE KEY publication_successor (predecessorId),
UNIQUE KEY publication_scope (id,companyId,resourceId),
KEY publication_asset (companyId,assetId,id),
KEY publication_resource (companyId,resourceId,id),
CONSTRAINT publication_active CHECK ((endedAt IS NULL AND endedBy IS NULL AND activeResourceId IS NOT NULL AND activeResourceId=resourceId) OR (endedAt IS NOT NULL AND endedBy IS NOT NULL AND activeResourceId IS NULL)),
CONSTRAINT publication_previous_identity CHECK (predecessorId IS NULL OR predecessorId<>id),
CONSTRAINT publication_period CHECK (endedAt IS NULL OR endedAt>=startedAt),
CONSTRAINT publication_asset_fk FOREIGN KEY (companyId,assetId) REFERENCES assets(companyId,id),
CONSTRAINT publication_share_fk FOREIGN KEY (resourceId,companyId) REFERENCES resource_network_shares(resourceId,companyId),
CONSTRAINT publication_previous_fk FOREIGN KEY (predecessorId,companyId,resourceId) REFERENCES asset_network_share_publications(id,companyId,resourceId),
CONSTRAINT publication_start_actor FOREIGN KEY (startedBy) REFERENCES users(id),
CONSTRAINT publication_end_actor FOREIGN KEY (endedBy) REFERENCES users(id)
) ENGINE=InnoDB`);
}

View File

@ -0,0 +1,130 @@
import type {
EntitySummaryResolver,
RelationshipContext,
RelationshipPage,
RelationshipQuery,
RelationshipQueryService,
RelationshipReadAccess,
RelationshipRegistry,
} from "@metis/relationship-contracts";
import {
RelationshipContextSchema,
RelationshipPageSchema,
RelationshipReadError,
validateRelationshipQuery,
} from "@metis/relationship-contracts";
import { Result } from "@repo/rdx-utils";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { NETWORK_SHARE_PUBLICATION_TYPE_ID } from "../../../common/network-share-publications";
import { readPublicationPage } from "./network-share-publication-read";
import { SignedQueryCursor } from "./signed-query-cursor";
export class NetworkSharePublicationQuery implements RelationshipQueryService {
private readonly cursors = new SignedQueryCursor();
constructor(
private readonly database: Sequelize,
private readonly registry: RelationshipRegistry,
private readonly access: RelationshipReadAccess,
private readonly shares: EntitySummaryResolver
) {}
async list(
input: RelationshipQuery,
context: RelationshipContext
): Promise<Result<RelationshipPage, RelationshipReadError>> {
if (!RelationshipContextSchema.safeParse(context).success) {
return Result.fail(new RelationshipReadError("FORBIDDEN"));
}
const authorized = await this.access.authorize(context);
if (!authorized.isSuccess) {
return authorized;
}
const checked = validateRelationshipQuery(input, context, this.registry);
if (!checked.isSuccess) {
return checked;
}
const query = checked.data;
if (query.typeId !== NETWORK_SHARE_PUBLICATION_TYPE_ID) {
return Result.fail(new RelationshipReadError("INVALID_QUERY"));
}
const { cursor: _cursor, ...filter } = query;
const scope = JSON.stringify([
1,
"publication-relationships",
context.companyId,
context.operatorId,
filter,
]);
const afterId = query.cursor ? this.cursors.decode(query.cursor, scope) : undefined;
if (query.cursor && !afterId) {
return Result.fail(new RelationshipReadError("INVALID_QUERY"));
}
const anchor = await this.anchor(query, context);
if (!anchor.isSuccess) {
return anchor;
}
if (!anchor.data) {
return Result.fail(new RelationshipReadError("UNAVAILABLE"));
}
const page = await readPublicationPage(this.database, context.companyId, {
...(query.anchor.entityType === "assets:asset"
? { assetId: query.anchor.id }
: { resourceId: query.anchor.id }),
includeEnded: query.includeEnded,
pageSize: query.pageSize,
...(afterId ? { afterId } : {}),
});
const last = page.rows.at(-1);
return Result.ok(
RelationshipPageSchema.parse({
items: page.rows.map((row) => ({
typeId: NETWORK_SHARE_PUBLICATION_TYPE_ID,
origin: { kind: "record", id: row.id },
source: {
providerId: "assets",
entityType: "assets:asset",
id: row.assetId,
companyId: context.companyId,
},
target: {
providerId: "resources",
entityType: "resources:resource",
id: row.resourceId,
companyId: context.companyId,
},
stateCode: row.endedAt === null ? "active" : "ended",
})),
nextCursor: page.hasMore && last ? this.cursors.encode(last.id, scope) : null,
})
);
}
private async anchor(
query: RelationshipQuery,
context: RelationshipContext
): Promise<Result<boolean, RelationshipReadError>> {
if (query.anchor.entityType === "assets:asset") {
return Result.ok(
(
await this.database.query("SELECT id FROM assets WHERE id=:id AND companyId=:companyId", {
replacements: { id: query.anchor.id, companyId: context.companyId },
type: QueryTypes.SELECT,
})
).length > 0
);
}
const result = await this.shares.resolveMany([query.anchor], context);
return result.isSuccess ? Result.ok(result.data[0]?.status === "resolved") : result;
}
}

View File

@ -0,0 +1,50 @@
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { NetworkSharePublicationSchema } from "../../../common/network-share-publications";
/** Proyección keyset compartida por consultas especializadas y proveedor tipado. */
export async function readPublicationPage(
database: Sequelize,
companyId: string,
selection: {
assetId?: string;
resourceId?: string;
includeEnded: boolean;
afterId?: string;
pageSize: number;
},
transaction?: Transaction
) {
const conditions = ["p.companyId=:companyId"];
if (selection.assetId) {
conditions.push("p.assetId=:assetId");
}
if (selection.resourceId) {
conditions.push("p.resourceId=:resourceId");
}
if (!selection.includeEnded) {
conditions.push("p.endedAt IS NULL");
}
if (selection.afterId) {
conditions.push("p.id>:afterId");
}
const rows = await database.query(
`SELECT p.id,p.companyId,p.assetId,p.resourceId,p.predecessorId,p.startedAt,p.startedBy,p.endedAt,p.endedBy FROM asset_network_share_publications p WHERE ${conditions.join(" AND ")} ORDER BY p.id ASC LIMIT :limit`,
{
replacements: {
companyId,
assetId: selection.assetId ?? null,
resourceId: selection.resourceId ?? null,
afterId: selection.afterId ?? null,
limit: selection.pageSize + 1,
},
type: QueryTypes.SELECT,
...(transaction ? { transaction } : {}),
}
);
const parsed = rows.map((row) => NetworkSharePublicationSchema.parse(row));
return { rows: parsed.slice(0, selection.pageSize), hasMore: parsed.length > selection.pageSize };
}

View File

@ -0,0 +1,48 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { NetworkSharePublicationSchema } from "../../../common/network-share-publications";
import { NetworkSharePublication } from "../../domain/network-share-publication.aggregate";
function identity(input: string) {
const result = UniqueID.create(input);
if (!result.isSuccess) {
throw new Error("Identidad de publicación persistida inválida.");
}
return result.data;
}
export class NetworkSharePublicationMapper {
toDomain(input: unknown) {
const row = NetworkSharePublicationSchema.parse(input);
if (
(row.endedAt === null) !== (row.endedBy === null) ||
(row.endedAt !== null && row.endedAt < row.startedAt)
) {
throw new Error("Periodo de publicación persistido inválido.");
}
return NetworkSharePublication.rehydrate(
{
companyId: identity(row.companyId),
assetId: identity(row.assetId),
resourceId: identity(row.resourceId),
predecessorId: row.predecessorId ? Maybe.some(identity(row.predecessorId)) : Maybe.none(),
startedAt: row.startedAt,
startedBy: identity(row.startedBy),
endedAt: row.endedAt ? Maybe.some(row.endedAt) : Maybe.none(),
endedBy: row.endedBy ? Maybe.some(identity(row.endedBy)) : Maybe.none(),
},
identity(row.id)
);
}
toPersistence(item: NetworkSharePublication) {
const row = item.toPrimitive();
return { ...row, activeResourceId: item.active ? row.resourceId : null };
}
}

View File

@ -0,0 +1,253 @@
import type { INetworkShareReader } from "@metis/resources/services";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes, UniqueConstraintError } from "sequelize";
import { z } from "zod/v4";
import type {
NetworkSharePublicationCommand,
NetworkSharePublicationQuery,
} from "../../../common/network-share-publications";
import type { INetworkSharePublicationRepository } from "../../application/network-share-publication.repository";
import { AssetCatalogError } from "../../domain/asset-definition";
import type { NetworkSharePublication } from "../../domain/network-share-publication.aggregate";
import { NetworkSharePublicationMapper } from "./network-share-publication.mapper";
import { readPublicationPage } from "./network-share-publication-read";
import { SignedQueryCursor } from "./signed-query-cursor";
function identity(value: string) {
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Identidad de publicación inconsistente.");
}
return parsed.data;
}
const AssetSchema = z.object({ id: z.uuid(), status: z.string() });
export class SequelizeNetworkSharePublicationRepository
implements INetworkSharePublicationRepository
{
private readonly mapper = new NetworkSharePublicationMapper();
private readonly cursors = new SignedQueryCursor();
constructor(
private readonly database: Sequelize,
private readonly shares: (transaction: Transaction) => INetworkShareReader
) {}
private async asset(
companyId: UniqueID,
assetId: string,
transaction: Transaction,
lock: boolean
) {
const rows = z.array(AssetSchema).parse(
await this.database.query(
`SELECT id,status FROM assets WHERE companyId=:companyId AND id=:assetId${lock ? " FOR UPDATE" : ""}`,
{
replacements: { companyId: companyId.toPrimitive(), assetId },
transaction,
type: QueryTypes.SELECT,
}
)
);
const row = rows[0];
return row
? {
id: identity(row.id),
operational: ["operational", "available", "in_use"].includes(row.status),
}
: undefined;
}
async mutate(
companyId: UniqueID,
command: Parameters<INetworkSharePublicationRepository["mutate"]>[1],
action: Parameters<INetworkSharePublicationRepository["mutate"]>[2]
) {
try {
return await this.database.transaction(async (transaction) => {
const company = await this.database.query(
"SELECT id FROM companies WHERE id=:id FOR UPDATE",
{ replacements: { id: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
if (!company.length) {
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
}
const target = await this.resourceForCommand(companyId, command, transaction);
if (!target.isSuccess) {
return target;
}
const resourceId = target.data;
const publications = await this.read(
"companyId=:companyId AND resourceId=:resourceId",
{ companyId: companyId.toPrimitive(), resourceId },
transaction
);
const asset =
command.action === "end"
? undefined
: await this.asset(companyId, command.assetId, transaction, true);
const share = await this.shares(transaction).find(companyId, identity(resourceId));
const result = action({ publications, share, asset });
if (!result.isSuccess) {
return result;
}
if (result.data.ended) {
await this.persist(result.data.ended, publications, transaction);
}
await this.persist(result.data.publication, publications, transaction);
return Result.ok(result.data.publication);
});
} catch (error) {
if (error instanceof UniqueConstraintError) {
return Result.fail(new AssetCatalogError("PUBLICATION_CONFLICT"));
}
throw error;
}
}
private async resourceForCommand(
companyId: UniqueID,
command: NetworkSharePublicationCommand,
transaction: Transaction
) {
if (command.action === "publish") {
return Result.ok(command.resourceId);
}
const previous = (
await this.read(
"companyId=:companyId AND id=:id",
{ companyId: companyId.toPrimitive(), id: command.publicationId },
transaction
)
)[0];
return previous
? Result.ok(previous.resourceId.toPrimitive())
: Result.fail(new AssetCatalogError("PUBLICATION_NOT_FOUND"));
}
private async read(
where: string,
replacements: Record<string, string>,
transaction: Transaction
) {
const rows = await this.database.query(
`SELECT id,companyId,assetId,resourceId,predecessorId,startedAt,startedBy,endedAt,endedBy FROM asset_network_share_publications WHERE ${where} ORDER BY id`,
{ replacements, transaction, type: QueryTypes.SELECT }
);
return rows.map((row) => this.mapper.toDomain(row));
}
private async persist(
item: NetworkSharePublication,
previous: readonly NetworkSharePublication[],
transaction: Transaction
) {
const old = previous.find((entry) => entry.id.equals(item.id));
if (old === item) {
return;
}
const row = this.mapper.toPersistence(item);
if (old) {
await this.database.query(
"UPDATE asset_network_share_publications SET endedAt=:endedAt,endedBy=:endedBy,activeResourceId=:activeResourceId WHERE id=:id AND companyId=:companyId",
{ replacements: row, transaction }
);
} else {
const columns = Object.keys(row);
await this.database.query(
`INSERT INTO asset_network_share_publications (${columns.join(",")}) VALUES (${columns.map((column) => `:${column}`).join(",")})`,
{ replacements: row, transaction }
);
}
}
private async validateSelection(
companyId: UniqueID,
query: NetworkSharePublicationQuery,
transaction: Transaction
): Promise<Result<true, AssetCatalogError>> {
const company = await this.database.query("SELECT id FROM companies WHERE id=:id", {
replacements: { id: companyId.toPrimitive() },
transaction,
type: QueryTypes.SELECT,
});
if (!company.length) {
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
}
if (query.assetId && !(await this.asset(companyId, query.assetId, transaction, false))) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
if (
query.resourceId &&
!(await this.shares(transaction).find(companyId, identity(query.resourceId))).getOrUndefined()
) {
return Result.fail(new AssetCatalogError("SMB_RESOURCE_NOT_FOUND"));
}
return Result.ok(true);
}
async list(
companyId: UniqueID,
actorId: UniqueID,
query: Parameters<INetworkSharePublicationRepository["list"]>[2]
) {
const { cursor: _cursor, ...filter } = query;
const scope = JSON.stringify([
1,
"publication-records",
companyId.toPrimitive(),
actorId.toPrimitive(),
filter,
]);
const afterId = query.cursor ? this.cursors.decode(query.cursor, scope) : undefined;
if (query.cursor && !afterId) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return this.database.transaction(async (transaction) => {
const valid = await this.validateSelection(companyId, query, transaction);
if (!valid.isSuccess) {
return valid;
}
const page = await readPublicationPage(
this.database,
companyId.toPrimitive(),
{
...(query.assetId ? { assetId: query.assetId } : {}),
...(query.resourceId ? { resourceId: query.resourceId } : {}),
includeEnded: query.includeEnded === "true",
pageSize: query.pageSize,
...(afterId ? { afterId } : {}),
},
transaction
);
const last = page.rows.at(-1);
return Result.ok({
publications: page.rows,
nextCursor: page.hasMore && last ? this.cursors.encode(last.id, scope) : null,
});
});
}
}

View File

@ -0,0 +1,49 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod/v4";
const CursorSchema = z.strictObject({ afterId: z.uuid(), scope: z.string() });
/** Clave efímera por proveedor: un reinicio invalida el cursor, nunca la identidad persistida. */
export class SignedQueryCursor {
private readonly key = randomBytes(32);
encode(afterId: string, scope: string): string {
const payload = Buffer.from(JSON.stringify({ afterId, scope: scope })).toString("base64url");
const signature = createHmac("sha256", this.key).update(payload).digest("base64url");
return `${payload}.${signature}`;
}
decode(cursor: string, scope: string): string | undefined {
const [payload, signature, extra] = cursor.split(".");
if (
!(payload && signature) ||
extra !== undefined ||
!/^[A-Za-z0-9_-]+$/.test(payload) ||
!/^[A-Za-z0-9_-]{43}$/.test(signature)
) {
return undefined;
}
const expected = Buffer.from(
createHmac("sha256", this.key).update(payload).digest("base64url"),
"ascii"
);
const actual = Buffer.from(signature, "ascii");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return undefined;
}
try {
const decoded = CursorSchema.safeParse(
JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
);
return decoded.success && decoded.data.scope === scope ? decoded.data.afterId : undefined;
} catch {
// Un token firmado pero ilegible sigue siendo una entrada inválida, no un fallo de SQL.
return undefined;
}
}
}

View File

@ -3,7 +3,7 @@ import type {
RelationshipReadAccess,
RelationshipRegistry,
} from "@metis/relationship-contracts";
import type { IDirectoryDomainReader } from "@metis/resources/services";
import type { IDirectoryDomainReader, INetworkShareReader } from "@metis/resources/services";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { RequestHandler } from "express";
@ -16,6 +16,7 @@ import { AssetRelationshipUseCases } from "./application/asset-relationship.use-
import { BrandUseCases } from "./application/brand.use-cases";
import { DirectoryMembershipUseCases } from "./application/directory-membership.use-cases";
import { InfrastructureUseCases } from "./application/infrastructure.use-cases";
import { NetworkSharePublicationUseCases } from "./application/network-share-publication.use-cases";
import { SupportUseCases } from "./application/support.use-cases";
import { WorkstationUseCases } from "./application/workstation.use-cases";
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
@ -25,6 +26,7 @@ import { registerRelationshipRoutes } from "./infrastructure/http/asset-relation
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
import { registerDirectoryMembershipRoutes } from "./infrastructure/http/directory-membership.routes";
import { registerInfrastructureRoutes } from "./infrastructure/http/infrastructure.routes";
import { registerNetworkSharePublicationRoutes } from "./infrastructure/http/network-share-publication.routes";
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
import { migrateInventory } from "./infrastructure/persistence/asset.model";
@ -48,11 +50,15 @@ import { migrateDirectoryMemberships } from "./infrastructure/persistence/direct
import { migrateDirectoryMembershipReadIndex } from "./infrastructure/persistence/directory-membership-read-index";
import { DirectoryMembershipRelationshipQuery } from "./infrastructure/persistence/directory-membership-relationship-query";
import { migrateInfrastructure } from "./infrastructure/persistence/infrastructure-migration";
import { LocalIdentityAssetReader } from "./infrastructure/persistence/local-identity-asset-reader";
import { migrateNetworkSharePublications } from "./infrastructure/persistence/network-share-publication-migration";
import { NetworkSharePublicationQuery } from "./infrastructure/persistence/network-share-publication-query";
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.repository";
import { SequelizeDirectoryMembershipRepository } from "./infrastructure/persistence/sequelize-directory-membership.repository";
import { SequelizeInfrastructureRepository } from "./infrastructure/persistence/sequelize-infrastructure.repository";
import { SequelizeNetworkSharePublicationRepository } from "./infrastructure/persistence/sequelize-network-share-publication.repository";
import { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository";
import { migrateSupport } from "./infrastructure/persistence/support-migration";
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
@ -60,7 +66,8 @@ import { migrateWorkstations } from "./infrastructure/persistence/workstation.mo
export async function createAssetModule(
database: Sequelize,
authenticate: RequestHandler,
directoryDomains?: (transaction: Transaction) => IDirectoryDomainReader
directoryDomains?: (transaction: Transaction) => IDirectoryDomainReader,
networkShares?: (transaction: Transaction) => INetworkShareReader
) {
await migrateAssetCatalog(database);
await migrateAssetCatalogText(database);
@ -94,6 +101,14 @@ export async function createAssetModule(
await migrateDirectoryMemberships(database);
await migrateDirectoryMembershipReadIndex(database);
}
if (networkShares) {
await migrateNetworkSharePublications(database);
}
const publications = networkShares
? new NetworkSharePublicationUseCases(
new SequelizeNetworkSharePublicationRepository(database, networkShares)
)
: undefined;
const directoryMemberships = directoryDomains
? new DirectoryMembershipUseCases(
new SequelizeDirectoryMembershipRepository(database, directoryDomains)
@ -111,6 +126,13 @@ export async function createAssetModule(
return {
services: {
localIdentities: (transaction: Transaction) =>
new LocalIdentityAssetReader(database, transaction),
publicationReads: (
registry: RelationshipRegistry,
access: RelationshipReadAccess,
shares: EntitySummaryResolver
) => new NetworkSharePublicationQuery(database, registry, access, shares),
relationshipReads: (
registry: RelationshipRegistry,
access: RelationshipReadAccess,
@ -147,6 +169,9 @@ export async function createAssetModule(
},
},
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
if (publications) {
registerNetworkSharePublicationRoutes(app, publications, authenticate);
}
if (directoryMemberships) {
registerDirectoryMembershipRoutes(app, directoryMemberships, authenticate);
}

View File

@ -45,6 +45,7 @@ export * from "./directory-membership-relationship";
export * from "./directory-memberships";
export * from "./infrastructure";
export * from "./inventory";
export * from "./network-share-publications";
export * from "./relationships";
export * from "./support";
export * from "./workstations";

View File

@ -603,5 +603,9 @@
"INVALID_QUERY": "The query or cursor is no longer valid. Restart from the first page.",
"UNAVAILABLE": "The asset or domain is no longer available in this company."
}
},
"networkSharePublication": {
"resources": "Published SMB resources",
"publisher": "Publishing asset"
}
}

View File

@ -603,5 +603,9 @@
"INVALID_QUERY": "La consulta o el cursor ya no son válidos. Vuelve a la primera página.",
"UNAVAILABLE": "El activo o el dominio ya no está disponible en esta compañía."
}
},
"networkSharePublication": {
"resources": "Recursos SMB publicados",
"publisher": "Activo publicador"
}
}

View File

@ -0,0 +1,57 @@
import { RelationshipDefinitionSchema } from "@metis/relationship-contracts";
import { z } from "zod/v4";
export const NETWORK_SHARE_PUBLICATION_TYPE_ID = "assets:network-share-publication";
export const NETWORK_SHARE_PUBLICATION_DEFINITION = RelationshipDefinitionSchema.parse({
typeId: NETWORK_SHARE_PUBLICATION_TYPE_ID,
ownerProviderId: "assets",
source: { entityType: "assets:asset" },
target: { entityType: "resources:resource", requiredCapability: "network:smb-share" },
direction: "directed",
forwardLabelKey: "assets:networkSharePublication.resources",
inverseLabelKey: "assets:networkSharePublication.publisher",
activeCardinality: { maxTargetsPerSource: null, maxSourcesPerTarget: 1 },
representation: "record",
history: "lifecycle",
writeMode: "owner-command",
});
const id = z.uuid().transform((value) => value.toLowerCase());
export const NetworkSharePublicationCommandSchema = z.discriminatedUnion("action", [
z.strictObject({ action: z.literal("publish"), assetId: id, resourceId: id }),
z.strictObject({ action: z.literal("end"), publicationId: id }),
z.strictObject({ action: z.literal("transfer"), publicationId: id, assetId: id }),
]);
export type NetworkSharePublicationCommand = z.infer<typeof NetworkSharePublicationCommandSchema>;
export const NetworkSharePublicationSchema = z.strictObject({
id,
companyId: id,
assetId: id,
resourceId: id,
predecessorId: id.nullable(),
startedAt: z.iso.datetime(),
startedBy: id,
endedAt: z.iso.datetime().nullable(),
endedBy: id.nullable(),
});
export type NetworkSharePublicationDTO = z.infer<typeof NetworkSharePublicationSchema>;
export const NetworkSharePublicationQuerySchema = z.strictObject({
assetId: id.optional(),
resourceId: id.optional(),
includeEnded: z.enum(["true", "false"]).default("false"),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().min(1).max(2048).optional(),
});
export type NetworkSharePublicationQuery = z.infer<typeof NetworkSharePublicationQuerySchema>;
export const NetworkSharePublicationPageSchema = z.strictObject({
publications: z.array(NetworkSharePublicationSchema).max(100),
nextCursor: z.string().nullable(),
});

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx src/api/application/directory-context-plan.test.ts src/web/access/list/helpers/access-environment-filter.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx src/api/application/directory-context-plan.test.ts src/web/access/list/helpers/access-environment-filter.test.ts src/api/domain/folder-mapping-account.test.ts src/api/application/local-identity-plan.test.ts"
},
"dependencies": {
"@metis/core": "workspace:*",

View File

@ -0,0 +1,113 @@
import { projectAccess } from "../domain/access/access-projection";
import type { FolderMappingValues } from "../domain/value-objects/folder-mapping-values.vo";
import type { FolderContext } from "./folder-mapping.repository";
import { folderAvailable, folderReferences } from "./folder-mapping-policy";
/** Una cuenta compartida requiere también asociación documental con la persona del perfil. */
export function folderAccountUsable(values: FolderMappingValues, context: FolderContext) {
const id = values.accessAccountId.getOrUndefined()?.toPrimitive();
const profile = context.profiles.find((item) => item.id.equals(values.profileId));
const account = context.items.find((item) => item.id.toPrimitive() === id)?.values.data;
if (
!profile ||
account?.kind !== "account" ||
!account.active ||
(account.expiresOn !== null && account.expiresOn < context.today)
) {
return false;
}
const catalogActive = [account.systemId, account.environmentId].every(
(key) =>
key === null ||
context.items.some(
(item) =>
item.id.toPrimitive() === key && "active" in item.values.data && item.values.data.active
)
);
return (
catalogActive &&
context.items.some((item) => {
const data = item.values.data;
return (
data.kind === "account_person" &&
data.accountId === id &&
data.personId === profile.values.personId.toPrimitive() &&
data.startDate <= context.today &&
data.endDate === null &&
(data.configuration === "pending" ||
data.configuration === "configured" ||
data.configuration === null)
);
})
);
}
/** Restringe Coverage a vías cuya relación account_person identifica exactamente la cuenta elegida. */
export function folderAccountCoverage(
values: FolderMappingValues,
context: FolderContext
): "unknown" | "covered" | "not-covered" {
const accountId = values.accessAccountId.getOrUndefined()?.toPrimitive();
if (!accountId) {
return "unknown";
}
if (!(folderAvailable(values, context) && folderAccountUsable(values, context))) {
return "not-covered";
}
const references = folderReferences(values, context);
if (!references) {
return "not-covered";
}
const accountPeople = new Set(
context.items
.filter(
(item) =>
item.values.data.kind === "account_person" &&
item.values.data.accountId === accountId &&
item.values.data.personId === references.need.personId
)
.map((item) => item.id.toPrimitive())
);
const current = context.items.filter((item) => {
const data = item.values.data;
if ("startDate" in data && data.startDate > context.today) {
return false;
}
if (data.kind !== "coverage") {
return true;
}
if (!accountPeople.has(data.accountPersonId) || data.needId !== values.needId.toPrimitive()) {
return false;
}
const grant = context.items.find((entry) => entry.id.toPrimitive() === data.grantId)?.values
.data;
if (grant?.kind !== "grant" || grant.resourceId !== references.need.resourceId) {
return false;
}
if (grant.accountId !== null) {
return grant.accountId === accountId && data.membershipId === null;
}
const membership = context.items.find((entry) => entry.id.toPrimitive() === data.membershipId)
?.values.data;
return (
membership?.kind === "membership" &&
membership.accountId === accountId &&
membership.groupId === grant.groupId
);
});
return projectAccess(current, context.people, context.today).coverage.some(
(item) => item.needId === values.needId.toPrimitive() && item.covered
)
? "covered"
: "not-covered";
}

View File

@ -6,6 +6,7 @@ import { FolderMappingError } from "../domain/folder-mapping-values";
import { FolderMappingValues } from "../domain/value-objects/folder-mapping-values.vo";
import type { FolderContext } from "./folder-mapping.repository";
import { folderAccountUsable } from "./folder-mapping-account-policy";
import { folderAvailable, folderReferences } from "./folder-mapping-policy";
export function mapFolderInput(
@ -13,7 +14,10 @@ export function mapFolderInput(
context: FolderContext,
previous?: FolderMapping
) {
const mapped = FolderMappingValues.create(input);
const mapped = FolderMappingValues.create({
...input,
accessAccountId: resolveAccount(input, previous),
});
if (!mapped.isSuccess) {
return mapped;
@ -26,8 +30,20 @@ export function mapFolderInput(
if (previous && !previous.values.sameTarget(values)) {
return Result.fail(new FolderMappingError("FOLDER_TARGET_IMMUTABLE"));
}
const accountChanged =
!previous ||
previous.values.toPrimitive().accessAccountId !== values.toPrimitive().accessAccountId;
if (
values.accessAccountId.getOrUndefined() &&
accountChanged &&
!folderAccountUsable(values, context)
) {
return Result.fail(new FolderMappingError("FOLDER_ACCOUNT_INCOMPATIBLE"));
}
const changed =
!previous ||
accountChanged ||
previous.values.state !== values.state ||
!previous.values.path.equals(values.path) ||
previous.values.toPrimitive().driveLetter !== values.toPrimitive().driveLetter;
@ -78,3 +94,11 @@ export function mapFolderInput(
return mapped;
}
function resolveAccount(input: FolderMappingInput, previous?: FolderMapping) {
if (input.accessAccountId !== undefined) {
return input.accessAccountId;
}
return previous?.values.toPrimitive().accessAccountId ?? null;
}

View File

@ -2,6 +2,7 @@ import type { FolderMappingDTO } from "../../common/folder-mappings";
import type { FolderMapping } from "../domain/folder-mapping.aggregate";
import type { FolderContext } from "./folder-mapping.repository";
import { folderAccountCoverage } from "./folder-mapping-account-policy";
import { folderAvailable, folderCoverage } from "./folder-mapping-policy";
export function buildFolderResponse(
@ -18,6 +19,7 @@ export function buildFolderResponse(
companyId: item.companyId.toPrimitive(),
values: item.values.toPrimitive(),
permissionsCovered,
accessAccountCoverage: folderAccountCoverage(item.values, context),
needsReview: !(item.values.closed || folderAvailable(item.values, context)),
history: item.history.map((revision) => ({
id: revision.id.toPrimitive(),

View File

@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { AccessItem } from "../domain/access/access-item.entity";
import { AccessValues } from "../domain/access/access-values.vo";
import { AccessWorkspace } from "../domain/access/access-workspace.aggregate";
import { LocalIdentityMapper } from "../infrastructure/persistence/local-identity.mapper";
import type { DirectoryContextSnapshot } from "./directory-context-plan";
import { planLocalIdentity } from "./local-identity-plan";
function fixture() {
const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const asset = UniqueID.generateNewID();
const audit = { actorId: actor, recordedAt: "2026-10-20T12:00:00.000Z" };
const sys = AccessValues.create({ kind: "system", name: "Sistema renombrable", active: true });
assert.ok(sys.isSuccess);
const system = AccessItem.create(Maybe.some(company), sys.data, audit);
const env = AccessValues.create({
kind: "environment",
systemId: system.id.toPrimitive(),
name: "Equipo",
active: true,
});
assert.ok(env.isSuccess);
const environment = AccessItem.create(Maybe.some(company), env.data, audit);
const workspace = AccessWorkspace.rehydrate(
Maybe.some(company),
[system, environment],
UniqueID.generateNewID()
);
const snapshot: DirectoryContextSnapshot = {
workspace,
actors: new Map(),
context: { catalog: [], people: [], today: "2026-10-20" },
directorySystems: [],
directoryEnvironments: [],
};
const command = {
action: "associate",
assetId: asset.toPrimitive(),
environmentId: environment.id.toPrimitive(),
} as const;
return { company, actor, asset, audit, snapshot, command, environment, system };
}
test("identidad local: vínculo por UUID, alta y retirada idempotentes con identidad e historial protegidos", () => {
const f = fixture();
const linked = planLocalIdentity(
f.snapshot,
f.command,
f.company,
f.actor,
f.audit.recordedAt,
[]
);
assert.ok(linked.isSuccess);
assert.equal(linked.data.link.id.toPrimitive()[14], "7");
const repeat = planLocalIdentity(f.snapshot, f.command, f.company, f.actor, f.audit.recordedAt, [
linked.data.link,
]);
assert.ok(repeat.isSuccess);
assert.equal(repeat.data.link, linked.data.link);
const moved = planLocalIdentity(
f.snapshot,
{ ...f.command, assetId: UniqueID.generateNewID().toPrimitive() },
f.company,
f.actor,
f.audit.recordedAt,
[linked.data.link]
);
assert.equal(moved.isSuccess, false);
const retired = linked.data.link.retire(f.actor, f.audit.recordedAt);
assert.ok(retired.isSuccess);
const replay = retired.data.retire(f.actor, "invalid");
assert.ok(replay.isSuccess);
assert.equal(replay.data, retired.data);
assert.equal(
planLocalIdentity(f.snapshot, f.command, f.company, f.actor, f.audit.recordedAt, [retired.data])
.isSuccess,
false
);
const mapper = new LocalIdentityMapper();
assert.deepEqual(
mapper.toDomain(mapper.toPersistence(retired.data)).toPrimitive(),
retired.data.toPrimitive()
);
});
test("identidad local: clasificación explícita rechaza AD por sistema o entorno, sin comparar etiquetas", () => {
const f = fixture();
for (const snapshot of [
{ ...f.snapshot, directorySystems: [f.system.id.toPrimitive()] },
{ ...f.snapshot, directoryEnvironments: [f.environment.id.toPrimitive()] },
]) {
assert.equal(
planLocalIdentity(snapshot, f.command, f.company, f.actor, f.audit.recordedAt, []).isSuccess,
false
);
}
assert.equal(
planLocalIdentity(
f.snapshot,
{ ...f.command, environmentId: f.system.id.toPrimitive() },
f.company,
f.actor,
f.audit.recordedAt,
[]
).isSuccess,
false
);
assert.equal(
planLocalIdentity(
f.snapshot,
f.command,
UniqueID.generateNewID(),
f.actor,
f.audit.recordedAt,
[]
).isSuccess,
false
);
});
test("identidad local: crear entorno utiliza AccessWorkspace y conserva cuentas y sistema existentes", () => {
const f = fixture();
const created = planLocalIdentity(
f.snapshot,
{
action: "create",
systemId: f.system.id.toPrimitive(),
assetId: f.asset.toPrimitive(),
name: "Segundo entorno",
},
f.company,
f.actor,
f.audit.recordedAt,
[]
);
assert.ok(created.isSuccess);
assert.equal(created.data.workspace.items.length, 3);
assert.equal(created.data.workspace.items[0], f.system);
assert.equal(created.data.workspace.items[1], f.environment);
assert.equal(created.data.link.toPrimitive().systemId, f.system.id.toPrimitive());
assert.equal(created.data.link.toPrimitive().identityScope, "local-device");
});

View File

@ -0,0 +1,149 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { LocalIdentityCommand } from "../../common/local-identity-environments";
import { AccessError } from "../domain/access/access-error";
import { AccessItem } from "../domain/access/access-item.entity";
import { AccessValues } from "../domain/access/access-values.vo";
import { LocalIdentityEnvironment } from "../domain/local-identity-environment.entity";
import type { DirectoryContextSnapshot } from "./directory-context-plan";
export function planLocalIdentity(
snapshot: DirectoryContextSnapshot,
command: LocalIdentityCommand,
company: UniqueID,
actor: UniqueID,
now: string,
links: readonly LocalIdentityEnvironment[]
) {
const workspace = snapshot.workspace;
const previous =
command.action === "create"
? undefined
: links.find((item) => item.toPrimitive().environmentId === command.environmentId);
if (command.action === "retire") {
if (!previous) {
return Result.fail(new AccessError("ACCESS_NOT_FOUND"));
}
const ended = previous.retire(actor, now);
return ended.isSuccess ? Result.ok({ workspace, link: ended.data }) : ended;
}
if (previous) {
const data = previous.toPrimitive();
return data.assetId === command.assetId && data.endedAt === null
? Result.ok({ workspace, link: previous })
: Result.fail(new AccessError("IMMUTABLE_FIELDS"));
}
if (command.action === "create") {
return createEnvironment(snapshot, command, company, actor, now);
}
const environment = workspace.items.find(
(item) => item.id.toPrimitive() === command.environmentId
);
if (!environment) {
return Result.fail(new AccessError("ACCESS_NOT_FOUND"));
}
return linkEnvironment(snapshot, environment.id, command.assetId, company, actor, now);
}
function linkEnvironment(
snapshot: DirectoryContextSnapshot,
environmentId: UniqueID,
assetId: string,
companyId: UniqueID,
actorId: UniqueID,
now: string
) {
const environment = snapshot.workspace.items.find((item) => item.id.equals(environmentId));
const data = environment?.values.data;
if (
data?.kind !== "environment" ||
!data.active ||
snapshot.directoryEnvironments.includes(environmentId.toPrimitive()) ||
snapshot.directorySystems.includes(data.systemId)
) {
return Result.fail(new AccessError("SCOPE_MISMATCH"));
}
const system = [...snapshot.workspace.items, ...snapshot.context.catalog].find(
(item) => item.id.toPrimitive() === data.systemId
);
const ownerCompany = snapshot.workspace.companyId.getOrUndefined();
// La compañía del entorno pertenece al espacio cargado; el lector público valida el activo en la misma transacción.
if (
!ownerCompany?.equals(companyId) ||
system?.values.data.kind !== "system" ||
!system.values.data.active
) {
return Result.fail(new AccessError("SCOPE_MISMATCH"));
}
return createLink(snapshot, environmentId, assetId, system.id, companyId, actorId, now);
}
function createLink(
snapshot: DirectoryContextSnapshot,
environmentId: UniqueID,
assetId: string,
systemId: UniqueID,
companyId: UniqueID,
startedBy: UniqueID,
startedAt: string
) {
const asset = UniqueID.create(assetId);
if (!asset.isSuccess) {
return Result.fail(new AccessError("INVALID_REQUEST"));
}
const result = LocalIdentityEnvironment.create({
companyId,
environmentId,
assetId: asset.data,
systemId,
startedBy,
startedAt,
});
return result.isSuccess
? Result.ok({ workspace: snapshot.workspace, link: result.data })
: result;
}
function createEnvironment(
snapshot: DirectoryContextSnapshot,
command: Extract<LocalIdentityCommand, { action: "create" }>,
company: UniqueID,
actor: UniqueID,
now: string
) {
let workspace = snapshot.workspace;
const values = AccessValues.create({
kind: "environment",
name: command.name,
systemId: command.systemId,
active: true,
});
if (!values.isSuccess) {
return values;
}
const item = AccessItem.create(Maybe.some(company), values.data, {
actorId: actor,
recordedAt: now,
});
const saved = workspace.save(item, snapshot.context);
if (!saved.isSuccess) {
return saved;
}
workspace = saved.data;
return linkEnvironment({ ...snapshot, workspace }, item.id, command.assetId, company, actor, now);
}

View File

@ -0,0 +1,28 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type {
LocalIdentityCommand,
LocalIdentityEnvironmentDTO,
LocalIdentityQuery,
} from "../../common/local-identity-environments";
import type { AccessError } from "../domain/access/access-error";
import type { LocalIdentityEnvironment } from "../domain/local-identity-environment.entity";
export interface ILocalIdentityAssetReader {
find(companyId: UniqueID, assetId: UniqueID): Promise<Maybe<{ operational: boolean }>>;
}
export interface ILocalIdentityRepository {
execute(
company: UniqueID,
actor: UniqueID,
command: LocalIdentityCommand
): Promise<Result<LocalIdentityEnvironment, AccessError>>;
list(
company: UniqueID,
query: LocalIdentityQuery
): Promise<
Result<{ environments: LocalIdentityEnvironmentDTO[]; nextAfterId: string | null }, AccessError>
>;
}

View File

@ -0,0 +1,37 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
LocalIdentityCommandSchema,
LocalIdentityQuerySchema,
} from "../../common/local-identity-environments";
import { AccessError } from "../domain/access/access-error";
import type { ILocalIdentityRepository } from "./local-identity.repository";
export class LocalIdentityUseCases {
constructor(private readonly repository: ILocalIdentityRepository) {}
execute(companyId: string, actorId: string, input: unknown) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const command = LocalIdentityCommandSchema.safeParse(input);
if (!(company.isSuccess && actor.isSuccess && command.success)) {
return Promise.resolve(Result.fail(new AccessError("INVALID_REQUEST")));
}
return this.repository.execute(company.data, actor.data, command.data);
}
list(companyId: string, input: unknown) {
const company = UniqueID.create(companyId);
const query = LocalIdentityQuerySchema.safeParse(input);
if (!(company.isSuccess && query.success)) {
return Promise.resolve(Result.fail(new AccessError("INVALID_REQUEST")));
}
return this.repository.list(company.data, query.data);
}
}

View File

@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import type { FolderContext } from "../application/folder-mapping.repository";
import {
folderAccountCoverage,
folderAccountUsable,
} from "../application/folder-mapping-account-policy";
import { mapFolderInput } from "../application/folder-mapping-input.mapper";
import { buildFolderResponse } from "../application/folder-mapping-response.builder";
import type { AccessDefinition } from "./access/access-definition";
import { AccessValues } from "./access/access-values.vo";
import { FolderMapping } from "./folder-mapping.aggregate";
import type { FolderMappingInput } from "./folder-mapping-values";
import { FolderMappingValues } from "./value-objects/folder-mapping-values.vo";
import { WorkProfileValues } from "./value-objects/work-profile-values.vo";
import { WorkProfile } from "./work-profile.aggregate";
function fixture() {
const ids = {
company: UniqueID.generateNewID(),
actor: UniqueID.generateNewID(),
person: UniqueID.generateNewID(),
asset: UniqueID.generateNewID(),
system: UniqueID.generateNewID(),
account: UniqueID.generateNewID(),
other: UniqueID.generateNewID(),
relation: UniqueID.generateNewID(),
otherRelation: UniqueID.generateNewID(),
resource: UniqueID.generateNewID(),
permission: UniqueID.generateNewID(),
type: UniqueID.generateNewID(),
need: UniqueID.generateNewID(),
grant: UniqueID.generateNewID(),
coverage: UniqueID.generateNewID(),
group: UniqueID.generateNewID(),
membership: UniqueID.generateNewID(),
};
const now = "2026-10-20T12:00:00.000Z";
const item = (id: UniqueID, data: AccessDefinition) => {
const result = AccessValues.create(data);
assert.ok(result.isSuccess);
return { id, values: result.data };
};
const profileValues = WorkProfileValues.create({
name: "Perfil",
personId: ids.person.toPrimitive(),
assetId: ids.asset.toPrimitive(),
accountId: ids.other.toPrimitive(),
active: true,
notes: null,
});
assert.ok(profileValues.isSuccess);
const profile = WorkProfile.create(ids.company, profileValues.data, ids.actor, now);
assert.ok(profile.isSuccess);
const period = { startDate: "2020-01-01", endDate: null };
const account = {
kind: "account" as const,
systemId: ids.system.toPrimitive(),
environmentId: null,
usage: "shared" as const,
active: true,
expiresOn: null,
notes: null,
};
const context = {
mappings: [],
profiles: [profile.data],
people: [{ id: ids.person, active: true }],
assets: [{ id: ids.asset, notRetired: true }],
actors: new Map(),
today: "2026-10-20",
items: [
item(ids.system, { kind: "system", name: "Identidad", active: true }),
item(ids.account, { ...account, login: "primera" }),
item(ids.other, { ...account, login: "otra" }),
item(ids.relation, {
kind: "account_person",
accountId: ids.account.toPrimitive(),
personId: ids.person.toPrimitive(),
...period,
configuration: "configured",
}),
item(ids.otherRelation, {
kind: "account_person",
accountId: ids.other.toPrimitive(),
personId: ids.person.toPrimitive(),
...period,
configuration: "configured",
}),
item(ids.type, { kind: "resource_type", name: "Carpeta", active: true }),
item(ids.resource, {
kind: "resource",
name: "Documentos",
resourceTypeId: ids.type.toPrimitive(),
systemId: null,
environmentId: null,
reference: String.raw`\\NAS\Docs`,
notes: null,
active: true,
}),
item(ids.permission, {
kind: "permission",
name: "Leer",
active: true,
resourceTypeId: ids.type.toPrimitive(),
description: null,
}),
item(ids.need, {
kind: "need",
personId: ids.person.toPrimitive(),
resourceId: ids.resource.toPrimitive(),
permissionIds: [ids.permission.toPrimitive()],
...period,
notes: null,
}),
item(ids.grant, {
kind: "grant",
resourceId: ids.resource.toPrimitive(),
permissionId: ids.permission.toPrimitive(),
accountId: ids.account.toPrimitive(),
groupId: null,
...period,
configuration: "configured",
}),
item(ids.coverage, {
kind: "coverage",
needId: ids.need.toPrimitive(),
grantId: ids.grant.toPrimitive(),
accountPersonId: ids.relation.toPrimitive(),
membershipId: null,
...period,
}),
],
} satisfies FolderContext;
const input: FolderMappingInput = {
profileId: profile.data.id.toPrimitive(),
needId: ids.need.toPrimitive(),
path: String.raw`\\NAS\Docs`,
driveLetter: "F",
state: "configured",
notes: null,
};
const values = (patch: { accessAccountId?: string | null } = {}) => {
const result = FolderMappingValues.create({ ...input, ...patch });
assert.ok(result.isSuccess);
return result.data;
};
const replace = (id: UniqueID, data: AccessDefinition) => {
context.items = context.items.map((entry) => (entry.id.equals(id) ? item(id, data) : entry));
};
return { ids, item, context, input, values, replace, now, period };
}
test("FolderMapping: cuenta desconocida, cuenta alternativa y cobertura independiente de la necesidad", () => {
const f = fixture();
assert.equal(folderAccountCoverage(f.values(), f.context), "unknown");
assert.equal(
folderAccountCoverage(f.values({ accessAccountId: f.ids.account.toPrimitive() }), f.context),
"covered"
);
const item = FolderMapping.create(
f.ids.company,
f.values({ accessAccountId: f.ids.other.toPrimitive() }),
f.ids.actor,
f.now
);
assert.ok(item.isSuccess);
const response = buildFolderResponse(item.data, f.context);
assert.equal(response.permissionsCovered, true);
assert.equal(response.accessAccountCoverage, "not-covered");
assert.equal(response.values.accessAccountId, f.ids.other.toPrimitive());
});
test("FolderMapping: ausencia conserva cuenta, null la retira y cada cambio real crea revisión", () => {
const f = fixture();
const original = FolderMapping.create(
f.ids.company,
f.values({ accessAccountId: f.ids.account.toPrimitive() }),
f.ids.actor,
f.now
);
assert.ok(original.isSuccess);
const kept = mapFolderInput({ ...f.input, notes: "Editado" }, f.context, original.data);
assert.ok(kept.isSuccess);
assert.equal(kept.data.toPrimitive().accessAccountId, f.ids.account.toPrimitive());
const removed = mapFolderInput({ ...f.input, accessAccountId: null }, f.context, original.data);
assert.ok(removed.isSuccess);
const updated = original.data.update(removed.data, f.ids.actor, f.now);
assert.ok(updated.isSuccess);
assert.equal(updated.data.history.length, 2);
assert.equal(
updated.data.history[0]?.values.toPrimitive().accessAccountId,
f.ids.account.toPrimitive()
);
assert.equal(updated.data.values.toPrimitive().accessAccountId, null);
const same = updated.data.update(removed.data, f.ids.actor, f.now);
assert.ok(same.isSuccess);
assert.equal(same.data, updated.data);
});
test("FolderMapping: rechaza cuenta ausente, otro subtipo, otra persona, caducada o desactivada", () => {
const f = fixture();
for (const id of [UniqueID.generateNewID(), f.ids.system, f.ids.grant]) {
assert.equal(
mapFolderInput({ ...f.input, accessAccountId: id.toPrimitive() }, f.context).isSuccess,
false
);
}
const account = f.context.items.find((entry) => entry.id.equals(f.ids.account))?.values.data;
assert.ok(account?.kind === "account");
for (const patch of [{ active: false }, { expiresOn: "2026-01-01" }]) {
f.replace(f.ids.account, { ...account, ...patch });
assert.equal(
folderAccountUsable(f.values({ accessAccountId: f.ids.account.toPrimitive() }), f.context),
false
);
}
f.replace(f.ids.account, account);
f.context.items = f.context.items.filter((entry) => !entry.id.equals(f.ids.relation));
assert.equal(
mapFolderInput({ ...f.input, accessAccountId: f.ids.account.toPrimitive() }, f.context)
.isSuccess,
false
);
});
test("FolderMapping: Coverage por grupo exige pertenencia directa de la cuenta y configuración vigente", () => {
const f = fixture();
f.context.items = [
...f.context.items,
f.item(f.ids.group, {
kind: "group",
name: "Finanzas",
identifier: "finanzas",
active: true,
systemId: f.ids.system.toPrimitive(),
environmentId: null,
description: null,
templateId: null,
}),
f.item(f.ids.membership, {
kind: "membership",
accountId: f.ids.account.toPrimitive(),
groupId: f.ids.group.toPrimitive(),
...f.period,
configuration: "configured",
}),
];
const grant = f.context.items.find((entry) => entry.id.equals(f.ids.grant))?.values.data;
const coverage = f.context.items.find((entry) => entry.id.equals(f.ids.coverage))?.values.data;
const membership = f.context.items.find((entry) => entry.id.equals(f.ids.membership))?.values
.data;
assert.ok(
grant?.kind === "grant" && coverage?.kind === "coverage" && membership?.kind === "membership"
);
f.replace(f.ids.grant, { ...grant, accountId: null, groupId: f.ids.group.toPrimitive() });
f.replace(f.ids.coverage, { ...coverage, membershipId: f.ids.membership.toPrimitive() });
const values = f.values({ accessAccountId: f.ids.account.toPrimitive() });
assert.equal(folderAccountCoverage(values, f.context), "covered");
for (const change of [
{ accountId: f.ids.other.toPrimitive() },
{ endDate: "2026-10-19", configuration: "removed" as const },
{ configuration: "pending" as const },
{ startDate: "2027-01-01" },
]) {
f.replace(f.ids.membership, { ...membership, ...change });
assert.equal(folderAccountCoverage(values, f.context), "not-covered");
}
});
test("FolderMapping: cobertura derivada cambia al retirar concesión, necesidad, cuenta o asociación personal", () => {
for (const target of ["grant", "need", "account", "relation", "resource"] as const) {
const f = fixture();
const id = f.ids[target];
const data = f.context.items.find((entry) => entry.id.equals(id))?.values.data;
assert.ok(data);
if ("active" in data) {
f.replace(id, { ...data, active: false });
} else if ("endDate" in data) {
f.replace(id, { ...data, endDate: "2026-10-19" });
}
assert.equal(
folderAccountCoverage(f.values({ accessAccountId: f.ids.account.toPrimitive() }), f.context),
"not-covered"
);
}
});
test("FolderMapping: todas las autorizaciones necesarias deben proceder de la cuenta seleccionada", () => {
const f = fixture();
const need = f.context.items.find((item) => item.id.equals(f.ids.need))?.values.data;
assert.ok(need?.kind === "need");
f.replace(f.ids.need, {
...need,
permissionIds: [f.ids.permission.toPrimitive(), UniqueID.generateNewID().toPrimitive()],
});
assert.equal(
folderAccountCoverage(f.values({ accessAccountId: f.ids.account.toPrimitive() }), f.context),
"not-covered"
);
});

View File

@ -5,6 +5,7 @@ import { ConfigurationSchema } from "./access/access-definition";
export const FolderMappingInputSchema = z.strictObject({
profileId: z.uuid(),
needId: z.uuid(),
accessAccountId: z.uuid().nullable().optional(),
path: z.string().trim().min(1).max(255),
driveLetter: z
.string()

View File

@ -0,0 +1,69 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { AccessError } from "./access/access-error";
type Props = Readonly<{
companyId: UniqueID;
environmentId: UniqueID;
assetId: UniqueID;
systemId: UniqueID;
startedAt: string;
startedBy: UniqueID;
endedAt: Maybe<string>;
endedBy: Maybe<UniqueID>;
}>;
/** Clasificación local y vínculo propietario inmutable; no duplica el entorno de AccessWorkspace. */
export class LocalIdentityEnvironment extends DomainEntity<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(props: Omit<Props, "endedAt" | "endedBy">) {
if (!z.iso.datetime().safeParse(props.startedAt).success) {
return Result.fail(new AccessError("INVALID_REQUEST"));
}
return Result.ok(
new LocalIdentityEnvironment({ ...props, endedAt: Maybe.none(), endedBy: Maybe.none() })
);
}
static rehydrate(props: Props, id: UniqueID) {
return new LocalIdentityEnvironment(props, id);
}
retire(actor: UniqueID, now: string) {
if (this.props.endedAt.getOrUndefined()) {
return Result.ok(this);
}
if (!z.iso.datetime().safeParse(now).success || now < this.props.startedAt) {
return Result.fail(new AccessError("INVALID_REQUEST"));
}
return Result.ok(
new LocalIdentityEnvironment(
{ ...this.props, endedAt: Maybe.some(now), endedBy: Maybe.some(actor) },
this.id
)
);
}
toPrimitive() {
return {
id: this.id.toPrimitive(),
companyId: this.props.companyId.toPrimitive(),
environmentId: this.props.environmentId.toPrimitive(),
assetId: this.props.assetId.toPrimitive(),
systemId: this.props.systemId.toPrimitive(),
identityScope: "local-device" as const,
startedAt: this.props.startedAt,
startedBy: this.props.startedBy.toPrimitive(),
endedAt: this.props.endedAt.getOrUndefined() ?? null,
endedBy: this.props.endedBy.getOrUndefined()?.toPrimitive() ?? null,
};
}
}

View File

@ -14,7 +14,8 @@ export class FolderMappingValues {
public readonly path: SharedFolderPath,
public readonly driveLetter: Maybe<DriveLetter>,
public readonly state: FolderMappingInput["state"],
public readonly notes: Maybe<string>
public readonly notes: Maybe<string>,
public readonly accessAccountId: Maybe<UniqueID>
) {
Object.freeze(this);
}
@ -29,9 +30,10 @@ export class FolderMappingValues {
const profile = UniqueID.create(data.profileId);
const need = UniqueID.create(data.needId);
const path = SharedFolderPath.create(data.path);
const account = data.accessAccountId ? UniqueID.create(data.accessAccountId) : undefined;
const letter = data.driveLetter === null ? undefined : DriveLetter.create(data.driveLetter);
if (!(profile.isSuccess && need.isSuccess)) {
if (!(profile.isSuccess && need.isSuccess) || (account && !account.isSuccess)) {
return Result.fail(new FolderMappingError("INVALID_REQUEST"));
}
if (!path.isSuccess) {
@ -48,7 +50,8 @@ export class FolderMappingValues {
path.data,
letter?.isSuccess ? Maybe.some(letter.data) : Maybe.none(),
data.state,
data.notes ? Maybe.some(data.notes) : Maybe.none()
data.notes ? Maybe.some(data.notes) : Maybe.none(),
account?.isSuccess ? Maybe.some(account.data) : Maybe.none()
)
);
}
@ -67,6 +70,7 @@ export class FolderMappingValues {
this.path.equals(other.path) &&
this.toPrimitive().driveLetter === other.toPrimitive().driveLetter &&
this.state === other.state &&
this.toPrimitive().accessAccountId === other.toPrimitive().accessAccountId &&
this.toPrimitive().notes === other.toPrimitive().notes
);
}
@ -75,6 +79,7 @@ export class FolderMappingValues {
return {
profileId: this.profileId.toPrimitive(),
needId: this.needId.toPrimitive(),
accessAccountId: this.accessAccountId.getOrUndefined()?.toPrimitive() ?? null,
path: this.path.toPrimitive(),
driveLetter: this.driveLetter.getOrUndefined()?.toPrimitive() ?? null,
state: this.state,

View File

@ -0,0 +1,66 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import type { LocalIdentityUseCases } from "../../application/local-identity.use-cases";
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 registerLocalIdentityRoutes(
app: Application,
useCases: LocalIdentityUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId", (request, response, next) => {
const company = z.uuid().safeParse(request.params.companyId);
if (!company.success) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.list(company.data, request.query)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(result.data);
})
.catch(next);
});
router.post("/:companyId", (request, response, next) => {
const company = z.uuid().safeParse(request.params.companyId);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (
!(company.success && actor.success && z.strictObject({}).safeParse(request.query).success)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.execute(company.data, actor.data.userId, request.body)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ environment: result.data.toPrimitive() });
})
.catch(next);
});
app.use("/api/local-identity-environments", authenticate, router);
}

View File

@ -28,6 +28,7 @@ import {
writeAccessRow,
} from "./access-relational";
import { migrateDirectoryContexts } from "./directory-context-migration";
import { migrateLocalIdentityEnvironments } from "./local-identity-migration";
class WorkspaceModel extends Model<
InferAttributes<WorkspaceModel>,
@ -133,6 +134,7 @@ export async function migrateAccess(database: Sequelize) {
);
await initializeAccessValues(database);
await migrateDirectoryContexts(database);
await migrateLocalIdentityEnvironments(database);
ReferenceModel.init(
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },

View File

@ -137,6 +137,12 @@ export const folderSnapshot = {
references: { model: "access_items", key: "id" },
onDelete: "RESTRICT",
},
accessAccountId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: "access_items", key: "id" },
onDelete: "RESTRICT",
},
path: { type: DataTypes.STRING(255), allowNull: false },
driveLetter: { type: DataTypes.STRING(2), allowNull: true },
state: { type: DataTypes.STRING(20), allowNull: false },

View File

@ -0,0 +1,88 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
/** Campos técnicos de ámbito: las FK apuntan a identidades estables, nunca a los valores reconstruibles. */
export async function migrateFolderMappingAccounts(database: Sequelize) {
const q = database.getQueryInterface();
const indexes = await database.query(
"SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='folder_mappings' AND INDEX_NAME='folder_mapping_company_scope'",
{ type: QueryTypes.SELECT }
);
if (!indexes.length) {
await q.addIndex("folder_mappings", ["id", "companyId"], {
name: "folder_mapping_company_scope",
unique: true,
});
}
for (const table of ["folder_mappings", "folder_mapping_revisions"]) {
await prepareColumns(database, table);
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table }, type: QueryTypes.SELECT }
);
const prefix = table === "folder_mappings" ? "folder_account" : "folder_revision_account";
const clauses = [
["kind", "CHECK (accessAccountKind='account')"],
[
"nullable",
"CHECK ((accessAccountId IS NULL AND accessAccountWorkspaceId IS NULL) OR (accessAccountId IS NOT NULL AND accessAccountWorkspaceId IS NOT NULL))",
],
[
"item",
"FOREIGN KEY (accessAccountId,accessAccountWorkspaceId,accessAccountKind) REFERENCES access_items(id,workspaceId,kind)",
],
[
"company",
"FOREIGN KEY (accessAccountWorkspaceId,companyId) REFERENCES access_workspaces(id,companyId)",
],
...(table === "folder_mapping_revisions"
? [
[
"mapping",
"FOREIGN KEY (mappingId,companyId) REFERENCES folder_mappings(id,companyId)",
],
]
: []),
];
for (const [suffix, clause] of clauses) {
const name = `${prefix}_${suffix}`;
if (!constraints.some((item) => item.name === name)) {
await database.query(`ALTER TABLE ${table} ADD CONSTRAINT ${name} ${clause}`);
}
}
}
}
async function prepareColumns(database: Sequelize, table: string) {
const q = database.getQueryInterface();
const columns = await q.describeTable(table);
if (!columns.accessAccountId) {
await q.addColumn(table, "accessAccountId", { type: DataTypes.UUID, allowNull: true });
}
if (!columns.accessAccountWorkspaceId) {
await q.addColumn(table, "accessAccountWorkspaceId", {
type: DataTypes.UUID,
allowNull: true,
});
}
if (!columns.accessAccountKind) {
await q.addColumn(table, "accessAccountKind", {
type: DataTypes.STRING(30),
allowNull: false,
defaultValue: "account",
});
}
if (table === "folder_mapping_revisions" && !columns.companyId) {
await q.addColumn(table, "companyId", { type: DataTypes.UUID, allowNull: true });
}
if (table === "folder_mapping_revisions" && (!columns.companyId || columns.companyId.allowNull)) {
await database.query(
"UPDATE folder_mapping_revisions r JOIN folder_mappings m ON m.id=r.mappingId SET r.companyId=m.companyId WHERE r.companyId IS NULL"
);
await q.changeColumn(table, "companyId", { type: DataTypes.UUID, allowNull: false });
}
}

View File

@ -2,6 +2,7 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { folderSnapshot } from "./configuration-snapshots";
import { migrateFolderMappingAccounts } from "./folder-mapping-account-migration";
export async function migrateFolderMappings(database: Sequelize) {
const q = database.getQueryInterface();
@ -36,6 +37,7 @@ export async function migrateFolderMappings(database: Sequelize) {
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
}
await migrateFolderMappingAccounts(database);
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 }

View File

@ -0,0 +1,42 @@
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
/** Primero raíces de Access; la FK de Assets se completa antes de habilitar los comandos. */
export async function migrateLocalIdentityEnvironments(database: Sequelize) {
await database.query(`CREATE TABLE IF NOT EXISTS access_local_identity_environments (
id CHAR(36) BINARY NOT NULL PRIMARY KEY,
companyId CHAR(36) BINARY NOT NULL,
workspaceId CHAR(36) BINARY NOT NULL,
environmentId CHAR(36) BINARY NOT NULL,
assetId CHAR(36) BINARY NOT NULL,
systemId CHAR(36) BINARY NOT NULL,
systemWorkspaceId CHAR(36) BINARY NOT NULL,
environmentKind VARCHAR(30) NOT NULL DEFAULT 'environment',
systemKind VARCHAR(30) NOT NULL DEFAULT 'system',
identityScope VARCHAR(20) NOT NULL DEFAULT 'local-device',
startedAt VARCHAR(24) NOT NULL, startedBy CHAR(36) BINARY NOT NULL,
endedAt VARCHAR(24) NULL, endedBy CHAR(36) BINARY NULL,
UNIQUE KEY local_identity_environment (environmentId),
KEY local_identity_asset_page (companyId,assetId,id),
CONSTRAINT local_identity_kind CHECK (environmentKind='environment' AND systemKind='system' AND identityScope='local-device'),
CONSTRAINT local_identity_period CHECK ((endedAt IS NULL AND endedBy IS NULL) OR (endedAt IS NOT NULL AND endedBy IS NOT NULL AND endedAt>=startedAt)),
CONSTRAINT local_identity_environment_fk FOREIGN KEY (environmentId,workspaceId,environmentKind) REFERENCES access_items(id,workspaceId,kind),
CONSTRAINT local_identity_system_fk FOREIGN KEY (systemId,systemWorkspaceId,systemKind) REFERENCES access_items(id,workspaceId,kind),
CONSTRAINT local_identity_company FOREIGN KEY (workspaceId,companyId) REFERENCES access_workspaces(id,companyId),
CONSTRAINT local_identity_start_actor FOREIGN KEY (startedBy) REFERENCES users(id),
CONSTRAINT local_identity_end_actor FOREIGN KEY (endedBy) REFERENCES users(id)
) ENGINE=InnoDB`);
}
export async function completeLocalIdentityAssetConstraint(database: Sequelize) {
const constraints = await database.query(
"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='access_local_identity_environments' AND CONSTRAINT_NAME='local_identity_asset_fk'",
{ type: QueryTypes.SELECT }
);
if (!constraints.length) {
await database.query(
"ALTER TABLE access_local_identity_environments ADD CONSTRAINT local_identity_asset_fk FOREIGN KEY (companyId,assetId) REFERENCES assets(companyId,id)"
);
}
}

View File

@ -0,0 +1,37 @@
import { Maybe } from "@repo/rdx-utils";
import { LocalIdentityEnvironmentSchema } from "../../../common/local-identity-environments";
import { LocalIdentityEnvironment } from "../../domain/local-identity-environment.entity";
import { profileIdentity } from "./work-profile-persistence.mapper";
export class LocalIdentityMapper {
toDomain(input: unknown) {
const row = LocalIdentityEnvironmentSchema.parse(input);
if (
(row.endedAt === null) !== (row.endedBy === null) ||
(row.endedAt !== null && row.endedAt < row.startedAt)
) {
throw new Error("Periodo de identidad local inconsistente.");
}
return LocalIdentityEnvironment.rehydrate(
{
companyId: profileIdentity(row.companyId),
environmentId: profileIdentity(row.environmentId),
assetId: profileIdentity(row.assetId),
systemId: profileIdentity(row.systemId),
startedAt: row.startedAt,
startedBy: profileIdentity(row.startedBy),
endedAt: row.endedAt ? Maybe.some(row.endedAt) : Maybe.none(),
endedBy: row.endedBy ? Maybe.some(profileIdentity(row.endedBy)) : Maybe.none(),
},
profileIdentity(row.id)
);
}
toPersistence(item: LocalIdentityEnvironment) {
return item.toPrimitive();
}
}

View File

@ -0,0 +1,186 @@
import type { ResourceInput } from "@metis/resources/common";
import type { IResourceAccessWriter } from "@metis/resources/services";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { AccessError } from "../../domain/access/access-error";
import { AccessItem } from "../../domain/access/access-item.entity";
import { AccessValues } from "../../domain/access/access-values.vo";
import type { SequelizeAccessRepository } from "./access-persistence";
import { writeAccessRow } from "./access-relational";
const SMB_RESOURCE_TYPE_NAME = "Carpeta compartida SMB";
export class ResourceAccessWriter implements IResourceAccessWriter {
constructor(
private readonly database: Sequelize,
private readonly repository: SequelizeAccessRepository,
private readonly transaction: Transaction
) {}
async ensureSmbType(input: Parameters<IResourceAccessWriter["ensureSmbType"]>[0]) {
let typeId: string | undefined;
const result = await this.repository.updateInTransaction(
Maybe.some(input.companyId),
(snapshot) => {
const items = [...snapshot.context.catalog, ...snapshot.workspace.items];
const existing = input.preferredTypeId
? items.find((item) => item.id.toPrimitive() === input.preferredTypeId)
: items.find(
(item) =>
item.values.data.kind === "resource_type" &&
item.values.data.name.normalize("NFC").toLowerCase() ===
SMB_RESOURCE_TYPE_NAME.toLowerCase()
);
if (existing) {
if (existing.values.data.kind !== "resource_type" || !existing.values.data.active) {
return Result.fail(new AccessError("INACTIVE_TARGET"));
}
typeId = existing.id.toPrimitive();
return Result.ok(snapshot.workspace);
}
if (input.preferredTypeId) {
return Result.fail(new AccessError("INACTIVE_TARGET"));
}
const values = AccessValues.create({
kind: "resource_type",
name: SMB_RESOURCE_TYPE_NAME,
active: true,
});
if (!values.isSuccess) {
return values;
}
const item = AccessItem.create(Maybe.some(input.companyId), values.data, {
actorId: input.actorId,
recordedAt: input.recordedAt,
});
const saved = snapshot.workspace.save(item, snapshot.context);
if (saved.isSuccess) {
typeId = item.id.toPrimitive();
}
return saved;
},
this.transaction
);
if (!result.isSuccess) {
return Result.fail(result.error);
}
if (!typeId) {
throw new Error("Tipo de acceso SMB no construido.");
}
return Result.ok(typeId);
}
async project(input: Parameters<IResourceAccessWriter["project"]>[0]) {
const { companyId, resourceId: id } = input;
const integration = input.values.integration;
if (integration?.kind !== "access") {
throw new Error("Proyección de acceso incompatible.");
}
const roots = await this.database.query<{ id: string }>(
"SELECT id FROM access_workspaces WHERE companyId=:companyId",
{ replacements: { companyId }, transaction: this.transaction, type: QueryTypes.SELECT }
);
let workspaceId = roots[0]?.id;
if (!workspaceId) {
workspaceId = UniqueID.generateNewID().toPrimitive();
await this.database.query(
"INSERT INTO access_workspaces (id,companyId,scopeKey) VALUES (:id,:companyId,:companyId)",
{ replacements: { id: workspaceId, companyId }, transaction: this.transaction }
);
}
const existing = await this.database.query<{ workspaceId: string; kind: string }>(
"SELECT workspaceId,kind FROM access_items WHERE id=:id FOR UPDATE",
{ replacements: { id }, transaction: this.transaction, type: QueryTypes.SELECT }
);
if (existing.some((row) => row.workspaceId !== workspaceId || row.kind !== "resource")) {
throw new Error("Colisión de identidad en proyección de recurso.");
}
await writeAccessRow(
this.database,
{ id, workspaceId, kind: "resource", values: accessProjection(input.values) },
this.transaction,
existing.length ? "update" : "insert"
);
for (const [sequence, revision] of input.history.entries()) {
if (!(await projectionExists(this.database, revision.id, this.transaction))) {
await writeAccessRow(
this.database,
{
id: revision.id,
itemId: id,
sequence,
actorId: revision.actorId,
recordedAt: revision.recordedAt,
values: accessProjection(revision.values),
},
this.transaction,
"insert"
);
}
}
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
for (const targetId of [
integration.resourceTypeId,
integration.systemId,
integration.environmentId,
].filter(Boolean)) {
const existing = await this.database.query<{ itemId: string }>(
"SELECT itemId FROM access_references WHERE itemId=:id AND targetId=:targetId",
{ replacements: { id, targetId }, transaction: this.transaction, type: QueryTypes.SELECT }
);
if (!existing.length) {
await this.database.query(
"INSERT INTO access_references (itemId,targetId) VALUES (:id,:targetId)",
{ replacements: { id, targetId }, transaction: this.transaction }
);
}
}
}
}
function accessProjection(input: ResourceInput) {
const integration = input.integration;
if (integration?.kind !== "access") {
throw new Error("Recurso sin contexto de acceso.");
}
return AccessDefinitionSchema.parse({
kind: "resource",
name: input.name,
active: input.active,
resourceTypeId: integration.resourceTypeId,
systemId: integration.systemId,
environmentId: integration.environmentId,
reference: input.characteristics[integration.referenceFieldId] || null,
notes: input.notes || null,
});
}
async function projectionExists(database: Sequelize, id: string, transaction: Transaction) {
return (
(
await database.query("SELECT id FROM access_revisions WHERE id=:id", {
replacements: { id },
transaction,
type: QueryTypes.SELECT,
})
).length > 0
);
}

View File

@ -163,17 +163,32 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
if (previous !== candidate.data) {
const record = this.mapper.toPersistence(candidate.data);
const account = record.root.accessAccountId;
const scope = account
? await this.database.query<{ workspaceId: string }>(
"SELECT workspaceId FROM access_items WHERE id=:account AND kind='account'",
{ replacements: { account }, transaction, type: QueryTypes.SELECT }
)
: [];
const accessAccountWorkspaceId = scope[0]?.workspaceId ?? null;
await this.database.query(
"INSERT INTO folder_mappings (id,companyId,profileId,needId,path,driveLetter,state,notes,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:path,:driveLetter,:state,:notes,:currentKey,:driveKey) ON DUPLICATE KEY UPDATE path=VALUES(path),driveLetter=VALUES(driveLetter),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey),driveKey=VALUES(driveKey)",
{ replacements: record.root, transaction }
"INSERT INTO folder_mappings (id,companyId,profileId,needId,path,driveLetter,state,notes,currentKey,driveKey,accessAccountId,accessAccountWorkspaceId) VALUES (:id,:companyId,:profileId,:needId,:path,:driveLetter,:state,:notes,:currentKey,:driveKey,:accessAccountId,:accessAccountWorkspaceId) ON DUPLICATE KEY UPDATE accessAccountId=VALUES(accessAccountId),accessAccountWorkspaceId=VALUES(accessAccountWorkspaceId),path=VALUES(path),driveLetter=VALUES(driveLetter),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey),driveKey=VALUES(driveKey)",
{ replacements: { ...record.root, accessAccountWorkspaceId }, 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,profileId,needId,path,driveLetter,state,notes) VALUES (:id,:mappingId,:position,:actorId,:recordedAt,:profileId,:needId,:path,:driveLetter,:state,:notes)",
{ replacements: revision, transaction }
"INSERT INTO folder_mapping_revisions (id,mappingId,position,actorId,recordedAt,profileId,needId,path,driveLetter,state,notes,companyId,accessAccountId,accessAccountWorkspaceId) VALUES (:id,:mappingId,:position,:actorId,:recordedAt,:profileId,:needId,:path,:driveLetter,:state,:notes,:companyId,:accessAccountId,:accessAccountWorkspaceId)",
{
replacements: {
...revision,
companyId: company.toPrimitive(),
accessAccountWorkspaceId,
},
transaction,
}
);
}
}

View File

@ -0,0 +1,190 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes, UniqueConstraintError } from "sequelize";
import type {
LocalIdentityCommand,
LocalIdentityQuery,
} from "../../../common/local-identity-environments";
import type {
ILocalIdentityAssetReader,
ILocalIdentityRepository,
} from "../../application/local-identity.repository";
import { planLocalIdentity } from "../../application/local-identity-plan";
import { AccessError } from "../../domain/access/access-error";
import type { SequelizeAccessRepository } from "./access-persistence";
import { LocalIdentityMapper } from "./local-identity.mapper";
import { profileIdentity } from "./work-profile-persistence.mapper";
const PROJECTION =
"id,companyId,environmentId,assetId,systemId,identityScope,startedAt,startedBy,endedAt,endedBy";
export class SequelizeLocalIdentityRepository implements ILocalIdentityRepository {
private readonly mapper = new LocalIdentityMapper();
constructor(
private readonly database: Sequelize,
private readonly access: SequelizeAccessRepository,
private readonly assets: (transaction: Transaction) => ILocalIdentityAssetReader
) {}
async execute(company: UniqueID, actor: UniqueID, command: LocalIdentityCommand) {
try {
return await this.database.transaction((transaction) =>
this.executeInTransaction(company, actor, command, transaction)
);
} catch (error) {
if (error instanceof UniqueConstraintError) {
return Result.fail(new AccessError("ACCESS_CONFLICT"));
}
throw error;
}
}
private async executeInTransaction(
company: UniqueID,
actor: UniqueID,
command: LocalIdentityCommand,
transaction: Transaction
) {
await this.database.query(
"SELECT id FROM access_workspaces WHERE scopeKey='global' FOR UPDATE",
{ transaction, type: QueryTypes.SELECT }
);
const companies = await this.database.query(
"SELECT id FROM companies WHERE id=:company FOR UPDATE",
{ replacements: { company: company.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
if (!companies.length) {
return Result.fail(new AccessError("COMPANY_NOT_FOUND"));
}
if (command.action !== "retire") {
const asset = (
await this.assets(transaction).find(company, profileIdentity(command.assetId))
).getOrUndefined();
if (!asset) {
return Result.fail(new AccessError("ACCESS_NOT_FOUND"));
}
if (!asset.operational) {
return Result.fail(new AccessError("INACTIVE_TARGET"));
}
}
const rows = await this.database.query(
`SELECT ${PROJECTION} FROM access_local_identity_environments WHERE companyId=:company`,
{ replacements: { company: company.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
const links = rows.map((row) => this.mapper.toDomain(row));
let link: ReturnType<LocalIdentityMapper["toDomain"]> | undefined;
const result = await this.access.updateInTransaction(
Maybe.some(company),
(snapshot) => {
const planned = planLocalIdentity(
snapshot,
command,
company,
actor,
new Date().toISOString(),
links
);
if (!planned.isSuccess) {
return planned;
}
link = planned.data.link;
return Result.ok(planned.data.workspace);
},
transaction
);
if (!result.isSuccess) {
return result;
}
if (!link) {
throw new Error("Asociación local no construida.");
}
const row = this.mapper.toPersistence(link);
const previous = links.find((item) => item.id.equals(link?.id));
if (previous === link) {
return Result.ok(link);
}
if (previous) {
await this.database.query(
"UPDATE access_local_identity_environments SET endedAt=:endedAt,endedBy=:endedBy WHERE id=:id",
{ replacements: row, transaction }
);
} else {
await this.database.query(
"INSERT INTO access_local_identity_environments (id,companyId,workspaceId,environmentId,assetId,systemId,systemWorkspaceId,startedAt,startedBy) SELECT :id,:companyId,e.workspaceId,:environmentId,:assetId,:systemId,s.workspaceId,:startedAt,:startedBy FROM access_items e JOIN access_items s ON s.id=:systemId WHERE e.id=:environmentId",
{ replacements: row, transaction }
);
}
return Result.ok(link);
}
list(company: UniqueID, query: LocalIdentityQuery) {
return this.database.transaction(async (transaction) => {
const companyId = company.toPrimitive();
const exists = await this.database.query("SELECT id FROM companies WHERE id=:companyId", {
replacements: { companyId },
transaction,
type: QueryTypes.SELECT,
});
if (!exists.length) {
return Result.fail(new AccessError("COMPANY_NOT_FOUND"));
}
const conditions = localIdentityConditions(query);
const rows = await this.database.query(
`SELECT ${PROJECTION} FROM access_local_identity_environments WHERE ${conditions.join(" AND ")} ORDER BY id LIMIT :limit`,
{
replacements: {
companyId,
assetId: query.assetId ?? null,
environmentId: query.environmentId ?? null,
afterId: query.afterId ?? null,
limit: query.pageSize + 1,
},
transaction,
type: QueryTypes.SELECT,
}
);
const environments = rows
.slice(0, query.pageSize)
.map((row) => this.mapper.toDomain(row).toPrimitive());
const last = environments.at(-1);
return Result.ok({
environments,
nextAfterId: rows.length > query.pageSize && last ? last.id : null,
});
});
}
}
function localIdentityConditions(query: LocalIdentityQuery) {
const conditions = ["companyId=:companyId"];
if (query.assetId) {
conditions.push("assetId=:assetId");
}
if (query.environmentId) {
conditions.push("environmentId=:environmentId");
}
if (query.includeEnded === "false") {
conditions.push("endedAt IS NULL");
}
if (query.afterId) {
conditions.push("id>:afterId");
}
return conditions;
}

View File

@ -1,4 +1,8 @@
import type { IDirectoryContextWriter, IResourceInventoryService } from "@metis/resources/services";
import type {
IDirectoryContextWriter,
IResourceAccessWriter,
IResourceInventoryService,
} from "@metis/resources/services";
import type { RequestHandler } from "express";
import type { Sequelize, Transaction } from "sequelize";
@ -14,6 +18,8 @@ import type {
} from "./application/dashboard.use-cases";
import { DashboardUseCases } from "./application/dashboard.use-cases";
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
import type { ILocalIdentityAssetReader } from "./application/local-identity.repository";
import { LocalIdentityUseCases } from "./application/local-identity.use-cases";
import type { IMailInventoryService } from "./application/mail-configuration.repository";
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
import { MailboxUseCases } from "./application/mailbox.use-cases";
@ -34,6 +40,7 @@ import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
import { registerCompanyLogoRoutes } from "./infrastructure/http/company-logo.routes";
import { registerDashboardRoutes } from "./infrastructure/http/dashboard.routes";
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
import { registerLocalIdentityRoutes } from "./infrastructure/http/local-identity.routes";
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
import { registerPersonPhotoRoutes } from "./infrastructure/http/person-photo.routes";
@ -56,6 +63,7 @@ import { migrateCompanyLogos } from "./infrastructure/persistence/company-logo-m
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
import { DirectoryContextWriter } from "./infrastructure/persistence/directory-context-writer";
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
import { completeLocalIdentityAssetConstraint } from "./infrastructure/persistence/local-identity-migration";
import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
@ -66,12 +74,14 @@ import { migratePrinterConfigurations } from "./infrastructure/persistence/print
import { migrateProcesses } from "./infrastructure/persistence/process-migration";
import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration";
import { removeArchivedSupportTables } from "./infrastructure/persistence/remove-archived-support-tables";
import { ResourceAccessWriter } from "./infrastructure/persistence/resource-access-writer";
import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
import { SequelizeCompanyLogoRepository } from "./infrastructure/persistence/sequelize-company-logo.repository";
import { SequelizeDashboardRepository } from "./infrastructure/persistence/sequelize-dashboard.repository";
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
import { SequelizeLocalIdentityRepository } from "./infrastructure/persistence/sequelize-local-identity.repository";
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
import { SequelizePersonPhotoRepository } from "./infrastructure/persistence/sequelize-person-photo.repository";
@ -125,6 +135,22 @@ export async function createCompanyModule(
return {
existence: new CompanyExistenceReader(database),
initializeLocalIdentities: async (
assets: (transaction: Transaction) => ILocalIdentityAssetReader
) => {
await completeLocalIdentityAssetConstraint(database);
const localIdentities = new LocalIdentityUseCases(
new SequelizeLocalIdentityRepository(database, accessRepository, assets)
);
return {
useCases: localIdentities,
register: (app: Parameters<typeof registerLocalIdentityRoutes>[0]) =>
registerLocalIdentityRoutes(app, localIdentities, authenticate),
};
},
resourceAccess: (transaction: Transaction): IResourceAccessWriter =>
new ResourceAccessWriter(database, accessRepository, transaction),
directoryContexts: (transaction: Transaction): IDirectoryContextWriter =>
new DirectoryContextWriter(database, accessRepository, transaction),
initializeDashboard: (assets: IAssetDashboardReader, resources: IInventoryDashboardReader) => {

View File

@ -3,7 +3,6 @@ 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,
@ -17,6 +16,7 @@ export const FolderMappingSchema = z.strictObject({
values: FolderMappingInputSchema,
needsReview: z.boolean(),
permissionsCovered: z.boolean(),
accessAccountCoverage: z.enum(["unknown", "covered", "not-covered"]),
history: z
.array(
z.strictObject({

View File

@ -15,6 +15,7 @@ export * from "./dto/shared/access.dto";
export * from "./dto/shared/company.dto";
export * from "./dto/shared/people.dto";
export * from "./folder-mappings";
export * from "./local-identity-environments";
export * from "./mail-configurations";
export * from "./mailboxes";
export * from "./printer-configurations";

View File

@ -0,0 +1,46 @@
import { z } from "zod/v4";
const id = z.uuid().transform((value) => value.toLowerCase());
export const LocalIdentityCommandSchema = z.discriminatedUnion("action", [
z.strictObject({
action: z.literal("create"),
assetId: id,
systemId: id,
name: z.string().trim().min(1).max(100),
}),
z.strictObject({ action: z.literal("associate"), environmentId: id, assetId: id }),
z.strictObject({ action: z.literal("retire"), environmentId: id }),
]);
export type LocalIdentityCommand = z.infer<typeof LocalIdentityCommandSchema>;
export const LocalIdentityEnvironmentSchema = z.strictObject({
id,
companyId: id,
environmentId: id,
assetId: id,
systemId: id,
identityScope: z.literal("local-device"),
startedAt: z.iso.datetime(),
startedBy: id,
endedAt: z.iso.datetime().nullable(),
endedBy: id.nullable(),
});
export type LocalIdentityEnvironmentDTO = z.infer<typeof LocalIdentityEnvironmentSchema>;
export const LocalIdentityQuerySchema = z.strictObject({
assetId: id.optional(),
environmentId: id.optional(),
includeEnded: z.enum(["true", "false"]).default("false"),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
afterId: id.optional(),
});
export type LocalIdentityQuery = z.infer<typeof LocalIdentityQuerySchema>;
export const LocalIdentityPageSchema = z.strictObject({
environments: z.array(LocalIdentityEnvironmentSchema).max(100),
nextAfterId: id.nullable(),
});

View File

@ -178,6 +178,7 @@ const folder = FolderMappingSchema.parse({
notes: null,
}),
permissionsCovered: false,
accessAccountCoverage: "unknown",
});
const station = WorkstationSchema.parse(
audited(24, {

View File

@ -11,7 +11,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/domain/resource-template-propagation.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-domain.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/domain/resource-template-propagation.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-domain.test.ts src/api/domain/network-share.test.ts"
},
"dependencies": {
"@metis/core": "workspace:*",

View File

@ -12,7 +12,7 @@ export function directoryDomainInput(input: ResourceInput, catalog: ResourceCata
(item) => item.id.toPrimitive() === input.templateId.toLowerCase()
);
if (!resourceTemplateCapability(template?.stockKey.getOrUndefined())) {
if (resourceTemplateCapability(template?.stockKey.getOrUndefined()) !== "directory:domain") {
return Result.ok(null);
}
const dnsId = catalog.definitions

View File

@ -0,0 +1,68 @@
import type { NetworkShareDiagnostic } from "../../common/network-shares";
import { SmbAddress } from "../domain/value-objects/smb-address.vo";
import type { InventoryContext } from "./resource.repository";
/** Las heurísticas solo señalan candidatos; nunca asignan capacidad ni convierten registros. */
export function diagnoseNetworkShares(
context: InventoryContext
): readonly NetworkShareDiagnostic[] {
const uncId = context.catalog.definitions
.find((item) => item.stockKey.getOrUndefined() === "field:unc")
?.id.toPrimitive();
const candidates = context.resources.flatMap((resource) => {
const values = resource.values.toPrimitive();
const key = context.catalog.definitions
.find((item) => item.id.toPrimitive() === values.templateId)
?.stockKey.getOrUndefined();
const certain = key === "template:shared_folder";
const reference =
values.integration?.kind === "access"
? values.characteristics[values.integration.referenceFieldId]
: undefined;
const guess =
reference ??
Object.values(values.characteristics).find(
(value) => typeof value === "string" && value.trim().startsWith("\\\\")
);
const raw = certain && uncId ? values.characteristics[uncId] : guess;
if (
!(
certain ||
(key === "template:access" && typeof raw === "string" && raw.trim().startsWith("\\\\"))
)
) {
return [];
}
const address = SmbAddress.create(raw);
return [{ resource, certain, raw, address }];
});
return candidates.map(({ resource, certain, raw, address }) => {
const managed = context.networkShares?.has(resource.id.toPrimitive()) ?? false;
const canonical = address.isSuccess ? address.data.normalizedAddress : undefined;
const duplicate =
canonical !== undefined &&
resource.values.toPrimitive().active &&
candidates.some(
(other) =>
!other.resource.id.equals(resource.id) &&
other.resource.values.toPrimitive().active &&
other.address.isSuccess &&
other.address.data.normalizedAddress === canonical
);
return {
resourceId: resource.id.toPrimitive(),
companyId: resource.companyId.toPrimitive(),
classification: certain ? "shared-folder" : "legacy-candidate",
addressStatus:
raw === undefined || raw === "" ? "missing" : address.isSuccess ? "valid" : "invalid",
duplicate,
managed,
requiresConversion: !managed,
};
});
}

View File

@ -0,0 +1,27 @@
import { Result } from "@repo/rdx-utils";
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
import { ResourceCatalogError } from "../domain/resource-definition";
import type { ResourceInput } from "../domain/resource-values";
import { SmbAddress } from "../domain/value-objects/smb-address.vo";
export function networkShareInput(input: ResourceInput, catalog: ResourceCatalog) {
const template = catalog.definitions.find(
(item) => item.id.toPrimitive() === input.templateId.toLowerCase()
);
if (template?.stockKey.getOrUndefined() !== "template:shared_folder") {
return Result.ok(null);
}
const field = catalog.definitions.find((item) => item.stockKey.getOrUndefined() === "field:unc");
const value = Object.entries(input.characteristics).find(
([key]) => key.toLowerCase() === field?.id.toPrimitive()
)?.[1];
const parsed = SmbAddress.create(value);
if (!(field && parsed.isSuccess)) {
return Result.fail(new ResourceCatalogError("INVALID_SMB_ADDRESS"));
}
return Result.ok({ fieldId: field.id.toPrimitive(), address: parsed.data });
}

View File

@ -0,0 +1,15 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import type { NetworkShareDTO } from "../../common/network-shares";
export type NetworkShareReference = NetworkShareDTO &
Readonly<{
id: UniqueID;
companyId: UniqueID;
active: boolean;
}>;
export interface INetworkShareReader {
find(companyId: UniqueID, resourceId: UniqueID): Promise<Maybe<NetworkShareReference>>;
}

View File

@ -0,0 +1,20 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { ResourceInput } from "../../common/inventory";
/** Puerto transaccional: Companies conserva la propiedad de sus tablas de accesos. */
export interface IResourceAccessWriter {
ensureSmbType(input: {
companyId: UniqueID;
actorId: UniqueID;
recordedAt: string;
preferredTypeId?: string;
}): Promise<Result<string, Error>>;
project(input: {
resourceId: string;
companyId: string;
values: ResourceInput;
history: readonly { id: string; values: ResourceInput; actorId: string; recordedAt: string }[];
}): Promise<void>;
}

View File

@ -8,6 +8,7 @@ import { ResourceSpecification, ResourceValues } from "../domain/value-objects/r
import { directoryDomainInput } from "./directory-domain-input";
import type { InventoryContext } from "./resource.repository";
import { mapSmbAccessInput } from "./smb-access-input";
export function mapResourceInput(
input: ResourceInput,
@ -41,7 +42,13 @@ export function mapResourceInput(
},
}
: input;
const compatibleInput = withMailboxIntegration(normalized, context);
const mailboxInput = withMailboxIntegration(normalized, context);
const integrated = mapSmbAccessInput(mailboxInput, context, previous);
if (!integrated.isSuccess) {
return integrated;
}
const compatibleInput = integrated.data;
const templateKey = context.catalog.definitions
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase())
?.stockKey.getOrUndefined();

View File

@ -1,14 +1,17 @@
import type { DirectoryDomainDTO } from "../../common/directory-domains";
import type { ResourceDTO } from "../../common/inventory";
import type { NetworkShareDTO } from "../../common/network-shares";
import type { Resource } from "../domain/resource.aggregate";
export function buildResourceResponse(
resource: Resource,
actors: ReadonlyMap<string, string>,
directory?: DirectoryDomainDTO
directory?: DirectoryDomainDTO,
networkShare?: NetworkShareDTO
): ResourceDTO {
return {
...(directory ? { directory } : {}),
...(networkShare ? { networkShare } : {}),
id: resource.id.toPrimitive(),
companyId: resource.companyId.toPrimitive(),
specification: resource.specification.toPrimitive(),

View File

@ -3,11 +3,14 @@ import type { Result } from "@repo/rdx-utils";
import type { DirectoryDomainDTO } from "../../common/directory-domains";
import type { ResourceReferenceDTO } from "../../common/inventory";
import type { NetworkShareDTO } from "../../common/network-shares";
import type { Resource } from "../domain/resource.aggregate";
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
import type { ResourceCatalogError } from "../domain/resource-definition";
export type InventoryContext = Readonly<{
smbAccessTypeId?: string;
networkShares?: ReadonlyMap<string, NetworkShareDTO>;
directoryDomains?: ReadonlyMap<string, DirectoryDomainDTO>;
catalog: ResourceCatalog;
resources: readonly Resource[];
@ -20,13 +23,15 @@ export interface IResourceRepository {
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;
save(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Resource, ResourceCatalogError>
action: (context: InventoryContext) => Result<Resource, ResourceCatalogError>,
creation?: { templateId: string; actorId: UniqueID }
): Promise<
Result<
{
resource: Resource;
actorNames: ReadonlyMap<string, string>;
directory?: DirectoryDomainDTO;
networkShare?: NetworkShareDTO;
},
ResourceCatalogError
>

View File

@ -70,62 +70,67 @@ export class ResourceUseCases {
return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST")));
}
return this.repository.save(company.data, (context) => {
const previous = identity?.isSuccess
? context.resources.find((resource) => resource.id.equals(identity.data))
: undefined;
return this.repository.save(
company.data,
(context) => {
const previous = identity?.isSuccess
? context.resources.find((resource) => resource.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
}
const mapped = mapResourceInput(input, company.data, context, previous);
if (id && !previous) {
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
}
const mapped = mapResourceInput(input, company.data, context, previous);
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
const current = mapped.data.values.toPrimitive();
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
const current = mapped.data.values.toPrimitive();
if (
current.integration?.kind === "mailbox" &&
context.resources.some((item) => {
const old = item.values.toPrimitive();
if (
current.integration?.kind === "mailbox" &&
context.resources.some((item) => {
const old = item.values.toPrimitive();
return (
!item.id.equals(previous?.id) &&
old.integration?.kind === "mailbox" &&
old.characteristics[old.integration.addressFieldId] ===
current.characteristics[
current.integration?.kind === "mailbox" ? current.integration.addressFieldId : ""
]
);
})
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
if (
context.resources.some(
(item) =>
!item.id.equals(previous?.id) &&
item.values.toPrimitive().templateId === mapped.data.values.toPrimitive().templateId &&
sameAccessContext(item.values.toPrimitive().integration, current.integration) &&
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
mapped.data.values.name.toPrimitive().toLocaleLowerCase("es")
)
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
const now = new Date().toISOString();
return (
!item.id.equals(previous?.id) &&
old.integration?.kind === "mailbox" &&
old.characteristics[old.integration.addressFieldId] ===
current.characteristics[
current.integration?.kind === "mailbox" ? current.integration.addressFieldId : ""
]
);
})
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
if (
context.resources.some(
(item) =>
!item.id.equals(previous?.id) &&
item.values.toPrimitive().templateId ===
mapped.data.values.toPrimitive().templateId &&
sameAccessContext(item.values.toPrimitive().integration, current.integration) &&
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
mapped.data.values.name.toPrimitive().toLocaleLowerCase("es")
)
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
const now = new Date().toISOString();
return previous
? previous.update(mapped.data.values, actor.data, now)
: Resource.create(
company.data,
mapped.data.specification,
mapped.data.values,
actor.data,
now
);
});
return previous
? previous.update(mapped.data.values, actor.data, now)
: Resource.create(
company.data,
mapped.data.specification,
mapped.data.values,
actor.data,
now
);
},
id ? undefined : { templateId: input.templateId, actorId: actor.data }
);
}
}

View File

@ -0,0 +1,52 @@
import { Result } from "@repo/rdx-utils";
import type { Resource } from "../domain/resource.aggregate";
import { ResourceCatalogError } from "../domain/resource-definition";
import type { ResourceInput } from "../domain/resource-values";
import { networkShareInput } from "./network-share-input";
import type { InventoryContext } from "./resource.repository";
/** Valida SMB gestionado; solo las altas reciben integración, nunca los registros anteriores. */
export function mapSmbAccessInput(
input: ResourceInput,
context: InventoryContext,
previous?: Resource
) {
if (previous && !context.networkShares?.has(previous.id.toPrimitive())) {
return Result.ok(input);
}
const share = networkShareInput(input, context.catalog);
if (!share.isSuccess) {
return share;
}
if (previous || !share.data) {
return Result.ok(input);
}
const typeId = context.smbAccessTypeId;
const supplied = input.integration;
if (!typeId) {
return Result.fail(new ResourceCatalogError("ACCESS_PROJECTION_UNAVAILABLE"));
}
if (
supplied &&
(supplied.kind !== "access" ||
supplied.referenceFieldId.toLowerCase() !== share.data.fieldId ||
supplied.resourceTypeId.toLowerCase() !== typeId ||
supplied.systemId !== null ||
supplied.environmentId !== null)
) {
return Result.fail(new ResourceCatalogError("SMB_ACCESS_INCOMPATIBLE"));
}
const integration = {
kind: "access",
referenceFieldId: share.data.fieldId,
resourceTypeId: typeId,
systemId: null,
environmentId: null,
} as const;
return Result.ok({ ...input, integration });
}

View File

@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { resourceTemplateCapability } from "../../common/resource-capabilities";
import { diagnoseNetworkShares } from "../application/network-share-diagnostics";
import type { InventoryContext } from "../application/resource.repository";
import { mapResourceInput } from "../application/resource-input.mapper";
import { Resource } from "./resource.aggregate";
import { ResourceCatalog } from "./resource-catalog.aggregate";
import { seedResourceCatalog } from "./resource-catalog-seed";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo";
import { SmbAddress } from "./value-objects/smb-address.vo";
for (const address of [
String.raw`\\NAS-01\Finanzas`,
String.raw`\\files.example.local\Docs`,
String.raw`\\192.168.5.10\C$`,
String.raw`\\samba\Diseño\Año 2026`,
"\\\\NAS\\Docs\\",
]) {
test(`SMB acepta ${address}`, () => {
const result = SmbAddress.create(address);
assert.ok(result.isSuccess);
assert.equal(result.data.address, address);
assert.ok(result.data.shareName);
});
}
for (const address of [
"",
"\\\\",
String.raw`\\host`,
String.raw`\\\share`,
String.raw`\\host\\`,
String.raw`C:\Datos`,
"https://host/share",
String.raw`\\host\share\..`,
String.raw`\\host\share\.`,
String.raw`\\?\UNC\host\share`,
String.raw`\\host\share\\child`,
String.raw`\\host\share.`,
String.raw`\\host\ share`,
String.raw`\\ho st\share`,
"\\\\host\\bad\u0000",
"\\\\host\\bad\ud800",
`\\\\host\\${"x".repeat(250)}`,
]) {
test(`SMB rechaza ${JSON.stringify(address)}`, () => {
assert.equal(SmbAddress.create(address).isSuccess, false);
});
}
test("SMB: comparación conservadora, alias y Unicode sin transformaciones destructivas", () => {
const parse = (text: string) => {
const result = SmbAddress.create(text);
assert.ok(result.isSuccess);
return result.data;
};
assert.equal(
parse(String.raw` \\NAS-01\Finanzas\ `).normalizedAddress,
parse(String.raw`\\nas-01\finanzas`).normalizedAddress
);
assert.notEqual(
parse(String.raw`\\nas\Docs`).normalizedAddress,
parse(String.raw`\\files.example.local\Docs`).normalizedAddress
);
assert.notEqual(
parse("\\\\nas\\Café").normalizedAddress,
parse("\\\\nas\\Cafe\u0301").normalizedAddress
);
assert.notEqual(
parse("\\\\nas\\ÁREA").normalizedAddress,
parse("\\\\nas\\área").normalizedAddress
);
assert.equal(parse(String.raw`\\nas\Docs\Equipo`).relativePath, "Equipo");
});
function fixture() {
const catalog = seedResourceCatalog(ResourceCatalog.create());
const template = catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:shared_folder"
);
const unc = catalog.definitions.find((item) => item.stockKey.getOrUndefined() === "field:unc");
assert.ok(template && unc);
const company = UniqueID.generateNewID();
const typeId = UniqueID.generateNewID().toPrimitive();
const context: InventoryContext = {
smbAccessTypeId: typeId,
catalog,
resources: [],
references: [{ id: typeId, kind: "resource_type", name: "SMB", active: true, systemId: null }],
actorNames: new Map(),
};
const input = {
templateId: template.id.toPrimitive(),
name: "Nombre independiente",
notes: "",
active: true,
characteristics: { [unc.id.toPrimitive()]: String.raw`\\NAS\Docs` },
};
return { catalog, template, unc, company, context, input };
}
test("SMB: semántica por stockKey y protección del catálogo sin bloquear etiquetas u obligatoriedad", () => {
const f = fixture();
assert.equal(
resourceTemplateCapability(f.template.stockKey.getOrUndefined()),
"network:smb-share"
);
assert.ok(f.template.values.data.kind === "template");
const without = ResourceDefinitionValues.create({
...f.template.values.data,
fields: f.template.values.data.fields.filter(
(field) => field.fieldId !== f.unc.id.toPrimitive()
),
});
assert.ok(without.isSuccess);
const removed = f.catalog.save(Maybe.none(), Maybe.some(f.template.id), without.data);
assert.ok(!removed.isSuccess);
assert.equal(removed.error.code, "SMB_FIELD_REQUIRED");
const renamed = ResourceDefinitionValues.create({
...f.template.values.data,
name: "Almacén documental",
});
assert.ok(renamed.isSuccess);
assert.ok(f.catalog.save(Maybe.none(), Maybe.some(f.template.id), renamed.data).isSuccess);
const fieldType = ResourceDefinitionValues.create({
...f.unc.values.data,
kind: "field",
name: "Dirección",
description: "",
type: "number",
unit: "",
choices: [],
});
assert.ok(fieldType.isSuccess);
assert.equal(f.catalog.save(Maybe.none(), Maybe.some(f.unc.id), fieldType.data).isSuccess, false);
});
test("SMB: alta validada y diagnóstico histórico sin conversión ni revisión", () => {
const f = fixture();
const mapped = mapResourceInput(f.input, f.company, f.context);
assert.ok(mapped.isSuccess);
assert.equal(
mapResourceInput({ ...f.input, characteristics: {} }, f.company, f.context).isSuccess,
false
);
const created = Resource.create(
f.company,
mapped.data.specification,
mapped.data.values,
UniqueID.generateNewID(),
"2026-10-20T12:00:00.000Z"
);
assert.ok(created.isSuccess);
const before = created.data.history;
const diagnosis = diagnoseNetworkShares({ ...f.context, resources: [created.data] });
assert.deepEqual(
diagnosis.map((item) => [
item.classification,
item.addressStatus,
item.managed,
item.requiresConversion,
]),
[["shared-folder", "valid", false, true]]
);
assert.equal(created.data.history, before);
});
test("SMB: nuevas altas fijan integración access y rechazan contexto incompatible", () => {
const f = fixture();
const mapped = mapResourceInput(f.input, f.company, f.context);
assert.ok(mapped.isSuccess);
const integration = mapped.data.values.toPrimitive().integration;
assert.ok(integration?.kind === "access");
assert.equal(integration.referenceFieldId, f.unc.id.toPrimitive());
assert.equal(integration.resourceTypeId, f.context.smbAccessTypeId);
assert.equal(integration.systemId, null);
assert.equal(integration.environmentId, null);
const incompatible = mapResourceInput(
{
...f.input,
integration: { ...integration, systemId: UniqueID.generateNewID().toPrimitive() },
},
f.company,
f.context
);
assert.ok(!incompatible.isSuccess);
assert.equal(incompatible.error.code, "SMB_ACCESS_INCOMPATIBLE");
const { smbAccessTypeId: _type, ...withoutType } = f.context;
const unavailable = mapResourceInput(f.input, f.company, withoutType);
assert.ok(!unavailable.isSuccess);
assert.equal(unavailable.error.code, "ACCESS_PROJECTION_UNAVAILABLE");
});

View File

@ -118,6 +118,18 @@ export class ResourceCatalog extends AggregateRoot<CatalogProps> {
return Result.fail(new ResourceCatalogError("DOMAIN_FIELDS_REQUIRED"));
}
}
if (candidate.stockKey.getOrUndefined() === "template:shared_folder") {
const data = candidate.values.data;
const unc = this.definitions.find((item) => item.stockKey.getOrUndefined() === "field:unc");
if (
data.kind !== "template" ||
!unc ||
!data.fields.some((binding) => binding.fieldId === unc.id.toPrimitive())
) {
return Result.fail(new ResourceCatalogError("SMB_FIELD_REQUIRED"));
}
}
const invalidField = this.invalidField(candidate, scope, previous);
if (invalidField) {

View File

@ -0,0 +1,55 @@
import { Result } from "@repo/rdx-utils";
import { ResourceCatalogError } from "../resource-definition";
/** UNC documental: conserva Unicode y alias; solo pliega ASCII para comparar SMB. */
export class SmbAddress {
private constructor(
readonly address: string,
readonly normalizedAddress: string,
readonly host: string,
readonly shareName: string,
readonly relativePath: string
) {
Object.freeze(this);
}
static create(input: unknown): Result<SmbAddress, ResourceCatalogError> {
if (typeof input !== "string" || /[\uD800-\uDFFF]/u.test(input)) {
return Result.fail(new ResourceCatalogError("INVALID_SMB_ADDRESS"));
}
const address = input.trim();
const comparable = address.replace(/\\$/, "");
const parts = comparable.slice(2).split("\\");
const [host, shareName] = parts;
if (
!address.startsWith("\\\\") ||
address.length > 255 ||
!host ||
!shareName ||
/[\s:@%]/u.test(host) ||
parts.some(
(part) =>
!part ||
part === "." ||
part === ".." ||
part !== part.trim() ||
part.endsWith(".") ||
/[\p{Cc}/\\:*?"<>|]/u.test(part)
)
) {
return Result.fail(new ResourceCatalogError("INVALID_SMB_ADDRESS"));
}
return Result.ok(
new SmbAddress(
address,
comparable.replace(/[A-Z]/g, (letter) => letter.toLowerCase()),
host,
shareName,
parts.slice(2).join("\\")
)
);
}
}

View File

@ -8,6 +8,7 @@ import {
SaveResourceRequestSchema,
SetResourceActiveRequestSchema,
} from "../../../common/inventory";
import { diagnoseNetworkShares } from "../../application/network-share-diagnostics";
import type { ResourceUseCases } from "../../application/resource.use-cases";
import { buildResourceResponse } from "../../application/resource-response.builder";
@ -35,7 +36,8 @@ function sendResource(
resource: buildResourceResponse(
result.data.resource,
result.data.actorNames,
result.data.directory
result.data.directory,
result.data.networkShare
),
});
}
@ -91,6 +93,27 @@ export function registerResourceRoutes(
});
}
router.get("/:companyId/network-share-diagnostics", (request, response, next) => {
const params = InventoryParamsSchema.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({ items: diagnoseNetworkShares(result.data) });
})
.catch(next);
});
router.get("/:companyId", (request, response, next) => {
const params = InventoryParamsSchema.safeParse(request.params);
@ -122,7 +145,8 @@ export function registerResourceRoutes(
buildResourceResponse(
resource,
result.data.actorNames,
result.data.directoryDomains?.get(resource.id.toPrimitive())
result.data.directoryDomains?.get(resource.id.toPrimitive()),
result.data.networkShares?.get(resource.id.toPrimitive())
)
),
});
@ -159,7 +183,8 @@ export function registerResourceRoutes(
resource: buildResourceResponse(
result.data.resource,
result.data.actorNames,
result.data.directory
result.data.directory,
result.data.networkShare
),
});
})

View File

@ -0,0 +1,22 @@
import type { Sequelize } from "sequelize";
/** Solo crea estructura: las carpetas históricas requieren conversión explícita. */
export async function migrateNetworkShares(database: Sequelize) {
await database.query(`CREATE TABLE IF NOT EXISTS resource_network_shares (
resourceId CHAR(36) BINARY NOT NULL PRIMARY KEY,
companyId CHAR(36) BINARY NOT NULL,
templateId CHAR(36) BINARY NOT NULL,
templateKey VARCHAR(100) NOT NULL DEFAULT 'template:shared_folder',
protocol VARCHAR(10) NOT NULL DEFAULT 'smb',
normalizedAddress VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
activeAddress VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
UNIQUE KEY network_share_active_address (companyId,activeAddress),
UNIQUE KEY network_share_company (resourceId,companyId),
CONSTRAINT network_share_protocol CHECK (protocol='smb'),
CONSTRAINT network_share_template CHECK (templateKey='template:shared_folder'),
CONSTRAINT network_share_address CHECK (CHAR_LENGTH(normalizedAddress)>0),
CONSTRAINT network_share_active CHECK (activeAddress IS NULL OR BINARY activeAddress=BINARY normalizedAddress),
CONSTRAINT network_share_resource FOREIGN KEY (resourceId,companyId,templateId) REFERENCES resources(id,companyId,templateId),
CONSTRAINT network_share_definition FOREIGN KEY (templateId,templateKey) REFERENCES resource_definitions(id,stockKey)
) ENGINE=InnoDB`);
}

View File

@ -0,0 +1,96 @@
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { NetworkShareSchema } from "../../../common/network-shares";
import { networkShareInput } from "../../application/network-share-input";
import type { Resource } from "../../domain/resource.aggregate";
import type { ResourceCatalog } from "../../domain/resource-catalog.aggregate";
export async function readNetworkShares(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
const rows = await database.query<{ resourceId: string; normalizedAddress: string }>(
"SELECT resourceId,normalizedAddress FROM resource_network_shares WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
return z.array(z.object({ resourceId: z.uuid(), normalizedAddress: z.string() })).parse(rows);
}
export function networkShareResponse(
resource: Resource,
catalog: ResourceCatalog,
normalizedAddress: string
) {
const parsed = networkShareInput(resource.values.toPrimitive(), catalog);
if (
!(parsed.isSuccess && parsed.data) ||
parsed.data.address.normalizedAddress !== normalizedAddress
) {
throw new Error("Proyección SMB inconsistente.");
}
return NetworkShareSchema.parse({
capability: "network:smb-share",
protocol: "smb",
...parsed.data.address,
});
}
/** No adopta registros antiguos: únicamente altas o especializaciones ya existentes. */
export async function projectNetworkShare(
database: Sequelize,
resource: Resource,
catalog: ResourceCatalog,
previous: Resource | undefined,
managed: boolean,
transaction: Transaction
) {
if (previous && !managed) {
return undefined;
}
const parsed = networkShareInput(resource.values.toPrimitive(), catalog);
if (!parsed.isSuccess) {
throw parsed.error;
}
if (!parsed.data) {
return undefined;
}
const values = resource.values.toPrimitive();
const normalizedAddress = parsed.data.address.normalizedAddress;
const replacements = {
resourceId: resource.id.toPrimitive(),
companyId: resource.companyId.toPrimitive(),
templateId: values.templateId,
normalizedAddress,
activeAddress: values.active ? normalizedAddress : null,
};
if (managed) {
const before = previous?.values.toPrimitive();
const oldAddress = before ? networkShareInput(before, catalog) : undefined;
if (
!(oldAddress?.isSuccess && oldAddress.data) ||
oldAddress.data.address.normalizedAddress !== normalizedAddress ||
before?.active !== values.active
) {
await database.query(
"UPDATE resource_network_shares SET normalizedAddress=:normalizedAddress,activeAddress=:activeAddress WHERE resourceId=:resourceId AND companyId=:companyId",
{ replacements, transaction }
);
}
} else {
await database.query(
"INSERT INTO resource_network_shares (resourceId,companyId,templateId,normalizedAddress,activeAddress) VALUES (:resourceId,:companyId,:templateId,:normalizedAddress,:activeAddress)",
{ replacements, transaction }
);
}
return networkShareResponse(resource, catalog, normalizedAddress);
}

View File

@ -0,0 +1,68 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import type {
INetworkShareReader,
NetworkShareReference,
} from "../../application/network-share-reader";
import { SmbAddress } from "../../domain/value-objects/smb-address.vo";
const RowSchema = z.object({
id: z.uuid(),
companyId: z.uuid(),
active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean),
address: z.string(),
normalizedAddress: z.string(),
});
/** Lectura pública con ámbito y bloqueo; el llamante aporta la transacción de su comando. */
export class NetworkShareReader implements INetworkShareReader {
constructor(
private readonly database: Sequelize,
private readonly transaction: Transaction
) {}
async find(companyId: UniqueID, resourceId: UniqueID) {
const rows = z.array(RowSchema).parse(
await this.database.query(
`SELECT r.id,r.companyId,r.active,c.textValue AS address,s.normalizedAddress
FROM resources r JOIN resource_network_shares s ON s.resourceId=r.id AND s.companyId=r.companyId
JOIN resource_characteristics c ON c.resourceId=r.id
JOIN resource_definitions f ON f.id=c.fieldId AND f.stockKey='field:unc'
WHERE r.id=:id AND r.companyId=:companyId FOR UPDATE`,
{
replacements: { id: resourceId.toPrimitive(), companyId: companyId.toPrimitive() },
transaction: this.transaction,
type: QueryTypes.SELECT,
}
)
);
const row = rows[0];
if (!row) {
return Maybe.none<NetworkShareReference>();
}
const address = SmbAddress.create(row.address);
const id = UniqueID.create(row.id);
const company = UniqueID.create(row.companyId);
if (
!(address.isSuccess && id.isSuccess && company.isSuccess) ||
address.data.normalizedAddress !== row.normalizedAddress
) {
throw new Error("Referencia SMB persistida inconsistente.");
}
return Maybe.some<NetworkShareReference>({
...address.data,
id: id.data,
companyId: company.data,
active: row.active,
capability: "network:smb-share",
protocol: "smb",
});
}
}

View File

@ -0,0 +1,101 @@
import type {
EntityReference,
EntitySummaryResolver,
RelationshipContext,
RelationshipReadAccess,
SummaryResolution,
} from "@metis/relationship-contracts";
import {
EntityReferenceSchema,
EntitySummarySchema,
RelationshipContextSchema,
RelationshipReadError,
} from "@metis/relationship-contracts";
import { Result } from "@repo/rdx-utils";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
const RowSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean),
normalizedAddress: z.string().min(1),
});
/** Una proyección por lote, sin hidratar inventarios ni cargar sus historiales. */
export class NetworkShareSummaryResolver implements EntitySummaryResolver {
readonly providerId = "resources";
readonly entityTypes = Object.freeze(["resources:resource"] as const);
constructor(
private readonly database: Sequelize,
private readonly access: RelationshipReadAccess
) {}
async resolveMany(
references: readonly EntityReference[],
context: RelationshipContext
): Promise<Result<readonly SummaryResolution[], RelationshipReadError>> {
if (!RelationshipContextSchema.safeParse(context).success) {
return Result.fail(new RelationshipReadError("FORBIDDEN"));
}
const authorized = await this.access.authorize(context);
if (!authorized.isSuccess) {
return authorized;
}
const parsed = z.array(EntityReferenceSchema).max(100).safeParse(references);
if (!parsed.success) {
return Result.fail(new RelationshipReadError("INVALID_QUERY"));
}
const accepted = parsed.data.filter(
(reference) =>
reference.companyId === context.companyId &&
reference.providerId === this.providerId &&
reference.entityType === "resources:resource"
);
const ids = [...new Set(accepted.map((reference) => reference.id))];
const rows =
ids.length === 0
? []
: z
.array(RowSchema)
.parse(
await this.database.query(
"SELECT r.id,r.name,r.active,d.normalizedAddress FROM resources r JOIN resource_network_shares d ON d.resourceId=r.id AND d.companyId=r.companyId WHERE r.companyId=:companyId AND r.id IN (:ids)",
{ type: QueryTypes.SELECT, replacements: { companyId: context.companyId, ids } }
)
);
const byId = new Map(rows.map((row) => [row.id, row]));
return Result.ok(
Object.freeze(
parsed.data.map((reference): SummaryResolution => {
const row =
reference.companyId === context.companyId &&
reference.providerId === this.providerId &&
reference.entityType === "resources:resource"
? byId.get(reference.id)
: undefined;
return row
? Object.freeze({
status: "resolved",
summary: EntitySummarySchema.parse({
reference,
name: row.name,
subtitle: row.normalizedAddress,
state: {
code: row.active ? "active" : "inactive",
labelKey: row.active ? "resources:active" : "resources:inactive",
},
}),
})
: Object.freeze({ status: "unavailable", reference });
})
)
);
}
}

View File

@ -0,0 +1,53 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import type { IResourceAccessWriter } from "../../application/resource-access-writer";
import { ResourceCatalogError } from "../../domain/resource-definition";
/** Preparación dentro de la transacción de alta; cualquier fallo posterior revierte también el tipo. */
export async function prepareSmbAccess(
context: InventoryContext,
companyId: UniqueID,
creation: Parameters<IResourceRepository["save"]>[2],
writer: IResourceAccessWriter | undefined
): Promise<InventoryContext> {
if (!creation) {
return context;
}
const template = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === creation.templateId.toLowerCase()
);
if (template?.stockKey.getOrUndefined() !== "template:shared_folder") {
return context;
}
if (!writer) {
throw new ResourceCatalogError("ACCESS_PROJECTION_UNAVAILABLE");
}
const prior = context.resources
.find(
(item) =>
item.values.toPrimitive().templateId === creation.templateId.toLowerCase() &&
item.values.toPrimitive().integration?.kind === "access"
)
?.values.toPrimitive().integration;
const ensured = await writer.ensureSmbType({
companyId,
actorId: creation.actorId,
recordedAt: new Date().toISOString(),
...(prior?.kind === "access" ? { preferredTypeId: prior.resourceTypeId } : {}),
});
if (!ensured.isSuccess) {
throw new ResourceCatalogError("ACCESS_PROJECTION_UNAVAILABLE");
}
return {
...context,
smbAccessTypeId: ensured.data,
references: [
...context.references,
{ id: ensured.data, kind: "resource_type", name: "", active: true, systemId: null },
],
};
}

View File

@ -8,6 +8,7 @@ import { ResourceCatalogError } from "../../domain/resource-definition";
import { sqlIdentifier } from "./resource-relational-sql";
const OWNED_TABLES = [
"resource_network_shares",
"resource_revision_characteristics",
"resource_revision_specification_choices",
"resource_revision_specification_fields",
@ -54,9 +55,21 @@ export async function removeResource(
transaction,
});
}
await database.query(
"SELECT resourceId FROM resource_network_shares WHERE resourceId=:id FOR UPDATE",
{
replacements: { id },
transaction,
type: QueryTypes.SELECT,
}
);
const references = await database.query<{ tableName: string; columnName: string; root: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName,REFERENCED_TABLE_NAME AS root FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IN (:roots) AND REFERENCED_COLUMN_NAME='id'",
{ replacements: { roots }, type: QueryTypes.SELECT, transaction }
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName,REFERENCED_TABLE_NAME AS root FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IN (:roots) AND REFERENCED_COLUMN_NAME IN ('id','resourceId')",
{
replacements: { roots: [...roots, "resource_network_shares"] },
type: QueryTypes.SELECT,
transaction,
}
);
for (const reference of references) {

View File

@ -1,9 +1,10 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import type { IResourceAccessWriter } from "../../application/resource-access-writer";
import type { Resource } from "../../domain/resource.aggregate";
import { ResourceCatalogError } from "../../domain/resource-definition";
import type { ResourceInput } from "../../domain/resource-values";
import { writeResourceRow } from "./resource-relational-sql";
@ -12,7 +13,8 @@ import { writeResourceRow } from "./resource-relational-sql";
export async function projectResource(
database: Sequelize,
resource: Resource,
transaction: Transaction
transaction: Transaction,
accessWriter?: IResourceAccessWriter
) {
const current = resource.values.toPrimitive();
const integration = current.integration;
@ -56,81 +58,20 @@ export async function projectResource(
return;
}
const roots = await database.query<{ id: string }>(
"SELECT id FROM access_workspaces WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
let workspaceId = roots[0]?.id;
if (!workspaceId) {
workspaceId = UniqueID.generateNewID().toPrimitive();
await database.query(
"INSERT INTO access_workspaces (id,companyId,scopeKey) VALUES (:id,:companyId,:companyId)",
{ replacements: { id: workspaceId, companyId }, transaction }
);
}
const values = accessProjection(current);
await writeResourceRow(
database,
"access_items",
{ id, workspaceId, kind: "resource" },
transaction
);
const { kind: _kind, ...columns } = scalarProjection(values);
await writeResourceRow(
database,
"access_resource_values",
{ ownerId: id, ...columns },
transaction
);
for (const [sequence, revision] of resource.history.entries()) {
const revisionId = revision.id.toPrimitive();
if (!(await projectionExists(database, "access_revisions", revisionId, transaction))) {
await writeResourceRow(
database,
"access_revisions",
{
id: revisionId,
itemId: id,
sequence,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
},
transaction
);
const { kind: _revisionKind, ...frozen } = scalarProjection(
accessProjection(revision.values.toPrimitive())
);
await writeResourceRow(
database,
"access_resource_revision_values",
{ ownerId: revisionId, ...frozen },
transaction
);
}
}
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
for (const targetId of [
integration.resourceTypeId,
integration.systemId,
integration.environmentId,
].filter(Boolean)) {
const existing = await database.query<{ itemId: string }>(
"SELECT itemId FROM access_references WHERE itemId=:id AND targetId=:targetId",
{ replacements: { id, targetId }, transaction, type: QueryTypes.SELECT }
);
if (!existing.length) {
await database.query(
"INSERT INTO access_references (itemId,targetId) VALUES (:id,:targetId)",
{ replacements: { id, targetId }, transaction }
);
}
if (!accessWriter) {
throw new ResourceCatalogError("ACCESS_PROJECTION_UNAVAILABLE");
}
await accessWriter.project({
resourceId: id,
companyId,
values: current,
history: resource.history.map((revision) => ({
id: revision.id.toPrimitive(),
values: revision.values.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
})),
});
}
function mailboxProjection(input: ResourceInput) {
@ -150,25 +91,6 @@ function mailboxProjection(input: ResourceInput) {
};
}
function accessProjection(input: ResourceInput) {
const integration = input.integration;
if (integration?.kind !== "access") {
throw new Error("Recurso sin contexto de acceso.");
}
return {
kind: "resource",
name: input.name,
active: input.active,
resourceTypeId: integration.resourceTypeId,
systemId: integration.systemId,
environmentId: integration.environmentId,
reference: input.characteristics[integration.referenceFieldId] || null,
notes: input.notes || null,
};
}
function scalarProjection(values: unknown) {
return z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))

View File

@ -1,15 +1,22 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { QueryTypes, UniqueConstraintError } from "sequelize";
import { z } from "zod/v4";
import { ResourceReferenceSchema } from "../../../common/inventory";
import type { IDirectoryContextWriter } from "../../application/directory-context-writer";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import type { IResourceAccessWriter } from "../../application/resource-access-writer";
import { ResourceCatalogError } from "../../domain/resource-definition";
import { projectDirectoryDomain, readDirectoryDomains } from "./directory-domain-projection";
import {
networkShareResponse,
projectNetworkShare,
readNetworkShares,
} from "./network-share-projection";
import { prepareSmbAccess } from "./prepare-smb-access";
import { removeResource } from "./remove-resource";
import {
ResourceAvailabilityModel,
@ -27,7 +34,8 @@ export class SequelizeResourceRepository implements IResourceRepository {
constructor(
private readonly database: Sequelize,
private readonly directoryContexts?: (transaction: Transaction) => IDirectoryContextWriter
private readonly directoryContexts?: (transaction: Transaction) => IDirectoryContextWriter,
private readonly accessWriters?: (transaction: Transaction) => IResourceAccessWriter
) {}
remove(companyId: UniqueID, id: UniqueID) {
@ -62,7 +70,7 @@ export class SequelizeResourceRepository implements IResourceRepository {
if (!root) {
throw new Error("Catálogo de recursos no inicializado.");
}
if (write && this.directoryContexts) {
if (write && (this.directoryContexts || this.accessWriters)) {
// Orden: catálogo de recursos, catálogo de accesos, compañía y workspace de accesos.
await this.database.query(
"SELECT id FROM access_workspaces WHERE scopeKey='global' FOR UPDATE",
@ -155,7 +163,21 @@ export class SequelizeResourceRepository implements IResourceRepository {
});
});
const shareRows = await readNetworkShares(this.database, companyId.toPrimitive(), transaction);
const networkShares = new Map(
shareRows.map((row) => {
const resource = resources.find((item) => item.id.toPrimitive() === row.resourceId);
if (!resource) {
throw new Error("Recurso SMB fuera de su compañía.");
}
return [row.resourceId, networkShareResponse(resource, catalog, row.normalizedAddress)];
})
);
return Result.ok({
networkShares,
directoryDomains: await readDirectoryDomains(
this.database,
companyId.toPrimitive(),
@ -172,7 +194,11 @@ export class SequelizeResourceRepository implements IResourceRepository {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
async save(companyId: UniqueID, action: Parameters<IResourceRepository["save"]>[1]) {
async save(
companyId: UniqueID,
action: Parameters<IResourceRepository["save"]>[1],
creation?: Parameters<IResourceRepository["save"]>[2]
) {
try {
return await this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
@ -180,14 +206,15 @@ export class SequelizeResourceRepository implements IResourceRepository {
if (!loaded.isSuccess) {
return loaded;
}
const changed = action(loaded.data);
const accessWriter = this.accessWriters?.(transaction);
const context = await prepareSmbAccess(loaded.data, companyId, creation, accessWriter);
const changed = action(context);
if (!changed.isSuccess) {
return changed;
throw changed.error;
}
const persistence = this.mapper.toPersistence(changed.data);
await projectResource(this.database, changed.data, transaction);
await writeInventory(
this.database,
persistence,
@ -213,13 +240,35 @@ export class SequelizeResourceRepository implements IResourceRepository {
throw directory.error;
}
const networkShare = await projectNetworkShare(
this.database,
changed.data,
loaded.data.catalog,
loaded.data.resources.find((item) => item.id.equals(changed.data.id)),
loaded.data.networkShares?.has(changed.data.id.toPrimitive()) ?? false,
transaction
);
await projectResource(this.database, changed.data, transaction, accessWriter);
return Result.ok({
...(networkShare ? { networkShare } : {}),
resource: changed.data,
actorNames: loaded.data.actorNames,
...(directory.data ? { directory: directory.data } : {}),
});
});
} catch (error) {
if (
error instanceof UniqueConstraintError &&
error.errors.some(
(item) =>
item.path === "network_share_active_address" ||
item.message.includes("network_share_active_address")
)
) {
return Result.fail(new ResourceCatalogError("SMB_ADDRESS_EXISTS"));
}
if (error instanceof ResourceCatalogError) {
return Result.fail(error);
}

View File

@ -6,7 +6,9 @@ import type { Sequelize, Transaction } from "sequelize";
import type { IDirectoryContextWriter } from "./application/directory-context-writer";
import type { IDirectoryDomainReader } from "./application/directory-domain-reader";
import type { INetworkShareReader } from "./application/network-share-reader";
import { ResourceUseCases } from "./application/resource.use-cases";
import type { IResourceAccessWriter } from "./application/resource-access-writer";
import { ResourceCatalogUseCases } from "./application/resource-catalog.use-cases";
import { ResourceInventoryService } from "./application/resource-inventory.service";
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
@ -18,6 +20,9 @@ import { DirectoryDomainReader } from "./infrastructure/persistence/directory-do
import { DirectoryDomainSummaryResolver } from "./infrastructure/persistence/directory-domain-summary-resolver";
import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources";
import { migrateSupportResources } from "./infrastructure/persistence/migrate-support-resources";
import { migrateNetworkShares } from "./infrastructure/persistence/network-share-migration";
import { NetworkShareReader } from "./infrastructure/persistence/network-share-reader";
import { NetworkShareSummaryResolver } from "./infrastructure/persistence/network-share-summary-resolver";
import { migrateInventory } from "./infrastructure/persistence/resource.model";
import {
ResourceCatalogModel,
@ -33,7 +38,8 @@ import { SequelizeResourceCatalogRepository } from "./infrastructure/persistence
export async function createResourceModule(
database: Sequelize,
authenticate: RequestHandler,
directoryContexts?: (transaction: Transaction) => IDirectoryContextWriter
directoryContexts?: (transaction: Transaction) => IDirectoryContextWriter,
accessWriters?: (transaction: Transaction) => IResourceAccessWriter
) {
await migrateResourceCatalog(database);
await ResourceCatalogModel.findOrCreate({
@ -55,13 +61,22 @@ export async function createResourceModule(
await migrateResourceRevisionSpecifications(database);
await enforceResourceConstraints(database);
await migrateDirectoryDomains(database);
await migrateNetworkShares(database);
await migrateSupportResources(database);
const catalog = new ResourceCatalogUseCases(repository);
const inventoryRepository = new SequelizeResourceRepository(database, directoryContexts);
const inventoryRepository = new SequelizeResourceRepository(
database,
directoryContexts,
accessWriters
);
const inventory = new ResourceUseCases(inventoryRepository);
return {
services: {
networkShareSummaries: (access: RelationshipReadAccess) =>
new NetworkShareSummaryResolver(database, access),
networkShares: (transaction: Transaction): INetworkShareReader =>
new NetworkShareReader(database, transaction),
directoryOptions: (access: RelationshipReadAccess) =>
new DirectoryDomainOptionsReader(database, access),
directorySummaries: (access: RelationshipReadAccess) =>

View File

@ -3,4 +3,9 @@ export type {
DirectoryDomainReference,
IDirectoryDomainReader,
} from "./application/directory-domain-reader";
export type {
INetworkShareReader,
NetworkShareReference,
} from "./application/network-share-reader";
export type { IResourceAccessWriter } from "./application/resource-access-writer";
export type { IResourceInventoryService } from "./application/resource-inventory.service";

View File

@ -42,4 +42,5 @@ export * from "./directory-domain-options";
export type { DirectoryDomainDTO } from "./directory-domains";
export { DirectoryDomainSchema } from "./directory-domains";
export * from "./inventory";
export * from "./network-shares";
export { resourceTemplateCapability } from "./resource-capabilities";

View File

@ -7,6 +7,7 @@ import {
} from "../api/domain/resource-values";
import { DirectoryDomainSchema } from "./directory-domains";
import { NetworkShareSchema } from "./network-shares";
export type { ResourceCharacteristic, ResourceInput } from "../api/domain/resource-values";
@ -33,6 +34,7 @@ export const ResourceRevisionSchema = z.strictObject({
export const ResourceSchema = z.strictObject({
directory: DirectoryDomainSchema.optional(),
networkShare: NetworkShareSchema.optional(),
id: z.uuid(),
companyId: z.uuid(),
specification: ResourceSpecificationSchema,

View File

@ -69,7 +69,11 @@
"RESOURCE_IN_USE": "The resource has current or historical associations. Deactivate it to preserve them or remove its associations before deleting it.",
"INVALID_DOMAIN_IDENTITY": "Check the domain DNS and NetBIOS names.",
"DOMAIN_DNS_EXISTS": "A domain with this DNS already exists in this company.",
"DIRECTORY_CONTEXT_UNAVAILABLE": "The Active Directory context could not be created or validated. Check existing systems and environments."
"DIRECTORY_CONTEXT_UNAVAILABLE": "The Active Directory context could not be created or validated. Check existing systems and environments.",
"INVALID_SMB_ADDRESS": "Enter a valid SMB UNC path.",
"SMB_ADDRESS_EXISTS": "An active SMB resource already uses this address.",
"ACCESS_PROJECTION_UNAVAILABLE": "The resource access integration could not be prepared.",
"SMB_ACCESS_INCOMPATIBLE": "The SMB folder requires its automatic integration without a system or environment."
},
"yes": "Yes",
"no": "No",
@ -166,7 +170,8 @@
"FORBIDDEN": "Your session does not allow this operation. Sign in again.",
"UNAUTHENTICATED": "Sign in again.",
"INVALID_SECTIONS": "Sections must have unique names and each field must belong to a section of this template.",
"DOMAIN_FIELDS_REQUIRED": "The domain template must keep its DNS and NetBIOS fields, with DNS required."
"DOMAIN_FIELDS_REQUIRED": "The domain template must keep its DNS and NetBIOS fields, with DNS required.",
"SMB_FIELD_REQUIRED": "The template must retain its UNC path field."
},
"fieldPicker": {
"destination": "Destination section: {{name}}",
@ -235,5 +240,8 @@
"directoryDescription": "Register a logical Windows domain independently of any server.",
"directoryExamples": "DNS: company.local. Optional NetBIOS: COMPANY.",
"directoryGuidance": "DNS is unique per company, ignoring case and a trailing dot. Use ASCII punycode for IDN. Editing or deactivating a domain preserves its context.",
"directoryScope": "This record documents the domain; it does not change Active Directory or create accounts, groups or servers."
"directoryScope": "This record documents the domain; it does not change Active Directory or create accounts, groups or servers.",
"smbAccessAutomatic": "Saving makes this folder available in Accounts and access to document needs and grants. No domain or publisher is required.",
"smbAccessConfigured": "Manage access in Accounts and access. Edit the address and status in this resource.",
"smbAccessHistorical": "This existing resource retains its access configuration. Saving does not convert it or add an integration."
}

View File

@ -69,7 +69,11 @@
"RESOURCE_IN_USE": "El recurso tiene asociaciones actuales o históricas. Desactívalo para conservarlas o retira sus asociaciones antes de eliminarlo.",
"INVALID_DOMAIN_IDENTITY": "Revisa el DNS y el nombre NetBIOS del dominio.",
"DOMAIN_DNS_EXISTS": "Ya existe un dominio con ese DNS en esta compañía.",
"DIRECTORY_CONTEXT_UNAVAILABLE": "No se pudo crear o validar el contexto de Active Directory. Comprueba los sistemas y entornos existentes."
"DIRECTORY_CONTEXT_UNAVAILABLE": "No se pudo crear o validar el contexto de Active Directory. Comprueba los sistemas y entornos existentes.",
"INVALID_SMB_ADDRESS": "Introduce una ruta UNC SMB válida.",
"SMB_ADDRESS_EXISTS": "Ya existe un recurso SMB activo con esta dirección.",
"ACCESS_PROJECTION_UNAVAILABLE": "No se puede preparar la integración de accesos del recurso.",
"SMB_ACCESS_INCOMPATIBLE": "La carpeta SMB debe conservar su integración automática sin sistema ni entorno."
},
"yes": "Sí",
"no": "No",
@ -166,7 +170,8 @@
"FORBIDDEN": "La sesión no permite esta operación. Vuelve a iniciar sesión.",
"UNAUTHENTICATED": "Vuelve a iniciar sesión.",
"INVALID_SECTIONS": "Las secciones deben tener nombres únicos y cada campo debe pertenecer a una sección de esta plantilla.",
"DOMAIN_FIELDS_REQUIRED": "La plantilla de dominio debe conservar sus campos DNS y NetBIOS, con DNS obligatorio."
"DOMAIN_FIELDS_REQUIRED": "La plantilla de dominio debe conservar sus campos DNS y NetBIOS, con DNS obligatorio.",
"SMB_FIELD_REQUIRED": "La plantilla debe conservar el campo de ruta UNC."
},
"fieldPicker": {
"destination": "Sección de destino: {{name}}",
@ -235,5 +240,8 @@
"directoryDescription": "Registra un dominio lógico Windows sin depender de un servidor.",
"directoryExamples": "DNS: empresa.local. NetBIOS opcional: EMPRESA.",
"directoryGuidance": "El DNS es único por compañía, sin distinguir mayúsculas ni punto final. Para IDN utiliza ASCII punycode. El contexto se conserva al editar o desactivar el dominio.",
"directoryScope": "Esta ficha documenta el dominio; no modifica Active Directory ni crea cuentas, grupos o servidores."
"directoryScope": "Esta ficha documenta el dominio; no modifica Active Directory ni crea cuentas, grupos o servidores.",
"smbAccessAutomatic": "Al guardar, la carpeta quedará disponible en Cuentas y accesos para documentar necesidades y concesiones. No requiere dominio ni publicador.",
"smbAccessConfigured": "Los accesos se gestionan en Cuentas y accesos. La dirección y el estado se editan en esta ficha.",
"smbAccessHistorical": "Este recurso anterior conserva su configuración de accesos. Guardarlo no lo convierte ni añade una integración."
}

View File

@ -0,0 +1,29 @@
import { z } from "zod/v4";
export const NetworkShareSchema = z.strictObject({
capability: z.literal("network:smb-share"),
protocol: z.literal("smb"),
address: z.string().min(1).max(255),
normalizedAddress: z.string().min(1).max(255),
host: z.string().min(1),
shareName: z.string().min(1),
relativePath: z.string(),
});
export type NetworkShareDTO = z.infer<typeof NetworkShareSchema>;
export const NetworkShareDiagnosticSchema = z.strictObject({
resourceId: z.uuid(),
companyId: z.uuid(),
classification: z.enum(["shared-folder", "legacy-candidate"]),
addressStatus: z.enum(["valid", "invalid", "missing"]),
duplicate: z.boolean(),
managed: z.boolean(),
requiresConversion: z.boolean(),
});
export const NetworkShareDiagnosticsSchema = z.strictObject({
items: z.array(NetworkShareDiagnosticSchema),
});
export type NetworkShareDiagnostic = z.infer<typeof NetworkShareDiagnosticSchema>;

View File

@ -1,4 +1,8 @@
/** Clasificación de plantillas de serie: stockKey es persistida, inmutable y ajena al idioma. */
export function resourceTemplateCapability(stockKey: string | null | undefined) {
return stockKey === "template:windows_domain" ? "directory:domain" : null;
return stockKey === "template:windows_domain"
? "directory:domain"
: stockKey === "template:shared_folder"
? "network:smb-share"
: null;
}

View File

@ -26,6 +26,7 @@ import { inventoryKeys } from "./api";
import { resourceFields } from "./helpers";
import { ResourceForm } from "./ui";
import { ResourceFields } from "./ui/resource-fields";
import { ResourceIntegrationFields } from "./ui/resource-integration-fields";
const companyId = UniqueID.generateNewID().toPrimitive();
@ -605,3 +606,37 @@ test("dominio: el formulario traduce el contexto pendiente y solo presenta un en
}
}
});
test("SMB: formulario explica acceso automático o histórico sin selectores incompatibles, es/en", async () => {
for (const language of ["es", "en"]) {
for (const fixed of [false, true]) {
const html = await render(
language,
<ResourceIntegrationFields
fixed={fixed}
integration={undefined}
items={[]}
onChange={() => {
throw new Error("La ayuda no debe modificar el formulario");
}}
references={[]}
templateKey="template:shared_folder"
/>
);
assert.equal(html.includes("combobox"), false);
assert.equal(html.includes("<button"), false);
assert.ok(
html.includes(
language === "es"
? fixed
? "recurso anterior"
: "Al guardar"
: fixed
? "existing resource"
: "Saving makes"
)
);
}
}
});

View File

@ -76,6 +76,11 @@ export function ResourceIntegrationFields({
</div>
);
}
if (templateKey === "template:shared_folder") {
return (
<p className="text-sm text-muted-foreground">{t(smbAccessHelpKey(integration, fixed))}</p>
);
}
if (templateKey !== "template:access" && integration?.kind !== "access") {
return null;
}
@ -158,3 +163,11 @@ export function ResourceIntegrationFields({
function optionalReference(value: string) {
return value === "none" ? null : value;
}
function smbAccessHelpKey(integration: ResourceInput["integration"], fixed: boolean) {
if (integration?.kind === "access") {
return "smbAccessConfigured";
}
return fixed ? "smbAccessHistorical" : "smbAccessAutomatic";
}