@@ -0,0 +1,135 @@
|
||||
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
|
||||
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
|
||||
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
|
||||
// source, the archive module, and the Archive port bound to the real generated client over the
|
||||
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
|
||||
// app turned the module on.
|
||||
|
||||
import { businessClient, type Client } from "@conusai-kit/business"
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, OBJECT_STORE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { files, filesModule } from "@conusai-kit/files"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { type FakeBusinessServices, fakeBusinessServices, fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { createClient } from "@conusai-sdk/archive/client"
|
||||
import { sdkArchiveService } from "../adapters/service.js"
|
||||
import { ARCHIVE_SERVICE } from "../core/ports.js"
|
||||
import { archiveModule } from "../module.js"
|
||||
|
||||
export interface Tenant {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly workspaceSlug: string
|
||||
readonly workspaceId: string
|
||||
readonly projectSlug: string
|
||||
readonly projectId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
export function archiveApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
|
||||
const fake: FakeBusinessServices = fakeBusinessServices(options)
|
||||
const client: Client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
|
||||
client.setConfig({ fetch: fake.fetch as typeof fetch })
|
||||
// The files module for its table alone: its procedures have their fixtures in test/isolation.test.ts.
|
||||
const registry = buildRegistry([tenancyModule, { ...filesModule, procedures: [] }, archiveModule])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OBJECT_STORE, fakeObjectStore())
|
||||
root.value(ARCHIVE_SERVICE, sdkArchiveService({ client, orgSlug: fake.orgSlug }))
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
/** An organization with one workspace and one project, owned by a new user. */
|
||||
const tenant = async (slug: string): Promise<Tenant> => {
|
||||
const owner = await signUp(`owner@${slug}.test`)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "archive",
|
||||
})) as { projectId: string; slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceSlug: workspace.slug,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: project.slug,
|
||||
projectId: project.projectId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A second user who joins `t`'s organization with the org role `role`. */
|
||||
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
|
||||
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
|
||||
token: string
|
||||
}
|
||||
const actor = await signUp(email)
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
|
||||
return call
|
||||
}
|
||||
|
||||
/** A file stored in `t`'s project, as the files module leaves an upload it has finished. */
|
||||
const storedFile = async (t: Tenant, sha256 = "a".repeat(64), size = 1234): Promise<string> => {
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
actor: t.owner,
|
||||
orgId: t.orgId,
|
||||
workspaceId: t.workspaceId,
|
||||
projectId: t.projectId,
|
||||
}
|
||||
const row = await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({
|
||||
status: "stored",
|
||||
storageKey: `files/${t.workspaceId}/${t.projectId}/${crypto.randomUUID()}`,
|
||||
filename: "record.pdf",
|
||||
contentType: "application/pdf",
|
||||
size,
|
||||
sha256,
|
||||
uploadedBy: t.owner.userId,
|
||||
storedAt: "2026-11-02T10:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return row.id
|
||||
}
|
||||
|
||||
return { fake, client, registry, data, caller, signUp, tenant, member, storedFile }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// The archive use cases over a recording port: what each asks the service, with no client and no network.
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { addVersion, listDocuments, registerDocument, requestKey, versionFacts } from "../core/archive.js"
|
||||
import { type ArchiveService, archiveReadsOf } from "../core/ports.js"
|
||||
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
actor: { userId: "01900000-0000-7000-0000-000000000009" },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
|
||||
const receipt = { documentId: "doc_1", revision: "1" }
|
||||
|
||||
function recording() {
|
||||
const asked: Array<[string, unknown]> = []
|
||||
const service: ArchiveService = {
|
||||
listDocuments: async (query) => {
|
||||
asked.push(["listDocuments", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
getDocument: async () => {
|
||||
throw new Error("not asked")
|
||||
},
|
||||
listVersions: async () => ({ rows: [] }),
|
||||
listRetentionClasses: async () => ({ rows: [] }),
|
||||
listDisposalDue: async () => ({ rows: [] }),
|
||||
registerDocument: async (input) => {
|
||||
asked.push(["registerDocument", input])
|
||||
return receipt
|
||||
},
|
||||
addVersion: async (input) => {
|
||||
asked.push(["addVersion", input])
|
||||
return receipt
|
||||
},
|
||||
setLegalHold: async () => receipt,
|
||||
disposeDocument: async () => receipt,
|
||||
createRetentionClass: async () => undefined,
|
||||
}
|
||||
return { service, asked }
|
||||
}
|
||||
|
||||
async function storedFile() {
|
||||
const data = createMemoryDataSource({ entities: [files] })
|
||||
const deps = { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY }
|
||||
const row = await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({
|
||||
status: "stored",
|
||||
storageKey: "files/w/p/f",
|
||||
filename: "record.pdf",
|
||||
contentType: "application/pdf",
|
||||
size: 1234,
|
||||
sha256: "a".repeat(64),
|
||||
uploadedBy: scope.actor.userId,
|
||||
storedAt: "2026-11-02T10:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return { deps, fileId: row.id }
|
||||
}
|
||||
|
||||
describe("the archive use cases", () => {
|
||||
it("list documents with a trimmed search and the module's page size", async () => {
|
||||
const { service, asked } = recording()
|
||||
await listDocuments(service, { q: " invoice " })
|
||||
await listDocuments(service, { q: " " })
|
||||
expect(asked).toEqual([
|
||||
["listDocuments", { q: "invoice", limit: 50 }],
|
||||
["listDocuments", { limit: 50 }],
|
||||
])
|
||||
})
|
||||
|
||||
it("take a version's facts from the stored file, with an opaque Blob reference", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
expect(await versionFacts(deps, scope, fileId)).toEqual({
|
||||
sha256: "a".repeat(64),
|
||||
mediaType: "application/pdf",
|
||||
size: 1234,
|
||||
blobRef: `conusai://blob/files/${fileId}`,
|
||||
})
|
||||
})
|
||||
|
||||
it("register a document with the project's request key and a trimmed title, and no kind unless asked", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { service, asked } = recording()
|
||||
await registerDocument(service, deps, scope, {
|
||||
title: " Signed contract ",
|
||||
kind: " ",
|
||||
retentionClass: "contracts",
|
||||
fileId,
|
||||
requestId: "r1",
|
||||
})
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
"registerDocument",
|
||||
{
|
||||
title: "Signed contract",
|
||||
retentionClass: "contracts",
|
||||
version: await versionFacts(deps, scope, fileId),
|
||||
key: requestKey(scope, "r1"),
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("add a version over the revision the page read", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { service, asked } = recording()
|
||||
await addVersion(service, deps, scope, { documentId: "doc_1", revision: "4", fileId, requestId: "r2" })
|
||||
expect(asked[0]?.[1]).toMatchObject({ documentId: "doc_1", revision: "4", key: requestKey(scope, "r2") })
|
||||
})
|
||||
|
||||
it("refuse a file this project does not hold before the service is asked", async () => {
|
||||
const { deps } = await storedFile()
|
||||
const { service, asked } = recording()
|
||||
await expect(
|
||||
registerDocument(service, deps, scope, {
|
||||
title: "Someone else's",
|
||||
retentionClass: "contracts",
|
||||
fileId: "01900000-0000-7000-0000-0000000000ff",
|
||||
requestId: "r3",
|
||||
}),
|
||||
).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
expect(asked).toEqual([])
|
||||
})
|
||||
|
||||
it("hand a read handler a view without a single write", () => {
|
||||
expect(Object.keys(archiveReadsOf(recording().service)).sort()).toEqual([
|
||||
"getDocument",
|
||||
"listDisposalDue",
|
||||
"listDocuments",
|
||||
"listRetentionClasses",
|
||||
"listVersions",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian, and no component of
|
||||
// the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { filesModule } from "@conusai-kit/files"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { archiveModule } from "../module.js"
|
||||
import {
|
||||
documentActionsLabels,
|
||||
documentListLabels,
|
||||
registerFormLabels,
|
||||
retentionFormLabels,
|
||||
retentionLabels,
|
||||
versionFormLabels,
|
||||
versionsLabels,
|
||||
} from "../ui/labels.js"
|
||||
|
||||
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
|
||||
const catalogues = buildRegistry([tenancyModule, filesModule, archiveModule]).catalogues
|
||||
const en = catalogues.en?.archive ?? {}
|
||||
const lt = catalogues.lt?.archive ?? {}
|
||||
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
documentListLabels(t, "failed", {})
|
||||
registerFormLabels(t, "failed", {})
|
||||
versionFormLabels(t, "failed", {})
|
||||
versionsLabels(t, 2)
|
||||
documentActionsLabels(t, "failed", {})
|
||||
retentionLabels(t)
|
||||
retentionFormLabels(t, "failed", {})
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the archive catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = keysUsed()
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing archive.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing archive.${key}`).toBeTruthy()
|
||||
}
|
||||
// The pages and the navigation ask for these directly.
|
||||
const direct = new Set([
|
||||
"title",
|
||||
"intro",
|
||||
"nav.documents",
|
||||
"nav.retention",
|
||||
"document.back",
|
||||
"document.retentionClass",
|
||||
"document.status",
|
||||
"document.revision",
|
||||
"document.created",
|
||||
"document.hold",
|
||||
"document.disposedAt",
|
||||
"document.yes",
|
||||
"document.no",
|
||||
"statuses.active",
|
||||
"statuses.disposed",
|
||||
"retention.title",
|
||||
"retention.intro",
|
||||
"retention.bases.created",
|
||||
"retention.bases.last_version",
|
||||
"retention.bases.event",
|
||||
])
|
||||
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("answer the navigation label the manifest names", () => {
|
||||
expect(archiveModule.ui?.nav?.map((item) => item.label)).toEqual(["archive.title"])
|
||||
})
|
||||
|
||||
it("count versions with the Lithuanian one, few and other forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { archive: en }, lt: { archive: lt } }).message("archive.versions.count", { count: n })
|
||||
expect(count("lt", 1)).toBe("1 versija")
|
||||
expect(count("lt", 2)).toBe("2 versijos")
|
||||
expect(count("lt", 10)).toBe("10 versijų")
|
||||
expect(count("lt", 21)).toBe("21 versija")
|
||||
expect(count("en", 2)).toBe("2 versions")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string and no colour literal", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(5)
|
||||
for (const { name, text } of sources) {
|
||||
expect(lintUiStrings(text, name), name).toEqual([])
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
|
||||
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
|
||||
// another's slugs, which the resolver must refuse before the Archive service is reached.
|
||||
//
|
||||
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
|
||||
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
|
||||
// its place whether or not the module is on, and checks only the procedures the app registers, so
|
||||
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
|
||||
// same fixtures against the module on its own, on or off.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
|
||||
export const archiveIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"archive.document.list": { input: (t) => inProjectOf(t) },
|
||||
"archive.document.get": { input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE }) },
|
||||
"archive.document.register": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
title: "Intruder",
|
||||
retentionClass: "anything",
|
||||
fileId: SOMEWHERE,
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
"archive.version.add": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
documentId: SOMEWHERE,
|
||||
revision: "1",
|
||||
fileId: SOMEWHERE,
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
"archive.document.hold": {
|
||||
input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE, revision: "1", legalHold: true, requestId: SOMEWHERE }),
|
||||
},
|
||||
"archive.document.dispose": {
|
||||
input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE, revision: "1", requestId: SOMEWHERE }),
|
||||
},
|
||||
"archive.retention.list": { input: (t) => inProjectOf(t) },
|
||||
"archive.retention.create": {
|
||||
input: (t) => ({ ...inProjectOf(t), code: "intruder", keepFor: "P1Y", from: "created", requestId: SOMEWHERE }),
|
||||
},
|
||||
"archive.disposal.list": { input: (t) => inProjectOf(t) },
|
||||
}
|
||||
|
||||
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = {
|
||||
seed: async () => ({}),
|
||||
fixtures: businessModulesOn.includes("archive") ? archiveIsolationFixtures : {},
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// The archive procedures end to end: the caller, the tenancy resolver, the module and the real generated
|
||||
// client against the business-services fake -- what a member sees, what the service gets, and each
|
||||
// refusal as the typed state a page shows.
|
||||
|
||||
import { describeBusinessRefusal } from "@conusai-kit/business/refusals"
|
||||
import type { AppError } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
|
||||
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { sdkArchiveService } from "../adapters/service.js"
|
||||
import { requestKey } from "../core/archive.js"
|
||||
import { archiveApp, type Tenant } from "./app.js"
|
||||
import { archiveIsolationFixtures } from "./isolation.js"
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
return error as AppError
|
||||
}
|
||||
throw new Error("the call was not refused")
|
||||
}
|
||||
|
||||
type App = ReturnType<typeof archiveApp>
|
||||
|
||||
const retention = async (t: Tenant, code = "contracts", keepFor = "P10Y") =>
|
||||
t.call.callByName("archive.retention.create", {
|
||||
...t.slugs,
|
||||
code,
|
||||
keepFor,
|
||||
from: "created",
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
const register = async (app: App, t: Tenant, title = "Signed contract", retentionClass = "contracts") =>
|
||||
(await t.call.callByName("archive.document.register", {
|
||||
...t.slugs,
|
||||
title,
|
||||
retentionClass,
|
||||
fileId: await app.storedFile(t),
|
||||
requestId: crypto.randomUUID(),
|
||||
})) as { documentId: string; revision: string }
|
||||
|
||||
describe("the archive procedures", () => {
|
||||
it("accepts incidental date-like digits in either UUID component without merging project keys", async () => {
|
||||
const app = archiveApp()
|
||||
const service = sdkArchiveService({ client: app.client, orgSlug: app.fake.orgSlug })
|
||||
const incidental = "0195a3c4-e12a-7b3f-8a1b-39001010000f"
|
||||
const ordinary = "0195a3c4-e12a-7b3f-8a1b-aaaaaaaaaaaa"
|
||||
const first = { code: "first", keepFor: "P10Y", from: "created" as const }
|
||||
const key = requestKey({ projectId: incidental }, ordinary)
|
||||
expect(key).toBe(`app-project:${incidental}:${ordinary}`)
|
||||
await service.createRetentionClass({ ...first, key })
|
||||
await service.createRetentionClass({ ...first, key })
|
||||
await service.createRetentionClass({
|
||||
code: "second",
|
||||
keepFor: "P10Y",
|
||||
from: "created",
|
||||
key: requestKey({ projectId: ordinary }, incidental),
|
||||
})
|
||||
await service.createRetentionClass({
|
||||
code: "third",
|
||||
keepFor: "P10Y",
|
||||
from: "created",
|
||||
key: requestKey({ projectId: ordinary }, ordinary),
|
||||
})
|
||||
expect(app.fake.archive.retentionClasses.size).toBe(3)
|
||||
})
|
||||
|
||||
it("accepts a request UUID with incidental date-like digits and replays its receipt", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const request = {
|
||||
...t.slugs,
|
||||
code: "contracts",
|
||||
keepFor: "P10Y",
|
||||
from: "created",
|
||||
requestId: "0195a3c4-e12a-7b3f-8a1b-39001010000f",
|
||||
}
|
||||
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
|
||||
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
|
||||
expect(app.fake.archive.retentionClasses.size).toBe(1)
|
||||
})
|
||||
|
||||
it("register a document once per request, list it, and read it with its versions", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const fileId = await app.storedFile(t)
|
||||
const request = {
|
||||
...t.slugs,
|
||||
title: "Signed contract",
|
||||
kind: "380",
|
||||
retentionClass: "contracts",
|
||||
fileId,
|
||||
requestId: crypto.randomUUID(),
|
||||
}
|
||||
const receipt = (await t.call.callByName("archive.document.register", request)) as {
|
||||
documentId: string
|
||||
revision: string
|
||||
}
|
||||
expect(receipt.revision).toBe("1")
|
||||
expect(await t.call.callByName("archive.document.register", request)).toEqual(receipt)
|
||||
expect(app.fake.archive.documents.size).toBe(1)
|
||||
const listed = await t.call.callByName("archive.document.list", t.slugs)
|
||||
expect(listed).toMatchObject({ rows: [{ documentId: receipt.documentId, title: "Signed contract", kind: "380" }] })
|
||||
|
||||
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId: receipt.documentId })
|
||||
expect(read).toMatchObject({
|
||||
document: { status: "active", retentionClass: "contracts", legalHold: false, revision: "1", disposed: false },
|
||||
versions: [{ sequence: 1, sha256: "a".repeat(64), mediaType: "application/pdf", size: "1234" }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(read))).toEqual(read)
|
||||
const [version] = [...app.fake.archive.versions.values()]
|
||||
expect(version?.blob_ref).toBe(`conusai://blob/files/${fileId}`)
|
||||
})
|
||||
|
||||
it("append a version over the revision it read, and refuse a stale one", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const added = await t.call.callByName("archive.version.add", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
fileId: await app.storedFile(t, "b".repeat(64), 99),
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
expect(added).toEqual({ documentId, revision: "2" })
|
||||
const stale = await refusal(
|
||||
t.call.callByName("archive.version.add", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
fileId: await app.storedFile(t, "c".repeat(64), 7),
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(stale.problem).toMatchObject({ status: 409, code: "business_conflict" })
|
||||
const read = (await t.call.callByName("archive.document.get", { ...t.slugs, documentId })) as {
|
||||
versions: Array<{ sequence: number; size: string }>
|
||||
}
|
||||
expect(read.versions.map((version) => [version.sequence, version.size])).toEqual([
|
||||
[1, "1234"],
|
||||
[2, "99"],
|
||||
])
|
||||
})
|
||||
|
||||
it("keep retention: a class, a hold that blocks disposal, the due list, and disposal when it has ended", async () => {
|
||||
let now = new Date("2026-11-02T10:00:00Z").getTime()
|
||||
const app = archiveApp({ clock: () => new Date(now) })
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t, "short", "P1D")
|
||||
expect(await t.call.callByName("archive.retention.list", t.slugs)).toMatchObject({
|
||||
rows: [{ code: "short", keepFor: "P1D", from: "created" }],
|
||||
})
|
||||
const { documentId } = await register(app, t, "Delivery note", "short")
|
||||
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
|
||||
|
||||
const early = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "1" }))
|
||||
expect(early.problem).toMatchObject({ status: 409, code: "business_retention_active" })
|
||||
|
||||
expect(await t.call.callByName("archive.document.hold", { ...request(), revision: "1", legalHold: true })).toEqual({
|
||||
documentId,
|
||||
revision: "2",
|
||||
})
|
||||
now += 2 * 24 * 3600 * 1000
|
||||
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toEqual({ rows: [] })
|
||||
const held = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "2" }))
|
||||
expect(held.problem).toMatchObject({ status: 409, code: "business_legal_hold" })
|
||||
// The person reads the kit's sentence for the refusal, in their language.
|
||||
expect(describeBusinessRefusal(held, "lt")).toBe(
|
||||
"Šiam įrašui taikomas teisinis sulaikymas, todėl jo keisti ar ištrinti negalima.",
|
||||
)
|
||||
|
||||
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: false })
|
||||
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
|
||||
await t.call.callByName("archive.document.dispose", { ...request(), revision: "3" })
|
||||
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
|
||||
expect(read).toMatchObject({ document: { status: "disposed", disposed: true } })
|
||||
})
|
||||
|
||||
it("show the service's refusals as the kit's codes", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const missing = await refusal(t.call.callByName("archive.document.get", { ...t.slugs, documentId: "nobody" }))
|
||||
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
||||
const noClass = await refusal(register(app, t, "Signed contract", "nothing"))
|
||||
expect(noClass.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
||||
const bad = await refusal(
|
||||
t.call.callByName("archive.retention.create", {
|
||||
...t.slugs,
|
||||
code: "bad",
|
||||
keepFor: "ten years",
|
||||
from: "created",
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(bad.problem).toMatchObject({ status: 400, code: "business_invalid" })
|
||||
})
|
||||
|
||||
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
|
||||
const app = archiveApp({ archive: false })
|
||||
const t = await app.tenant("alpha")
|
||||
const disabled = await refusal(t.call.callByName("archive.document.list", t.slugs))
|
||||
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
|
||||
app.fake.enable("archive")
|
||||
app.fake.revoke("archive.retention:write")
|
||||
const scoped = await refusal(retention(t))
|
||||
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
|
||||
expect(scoped.problem.detail).not.toContain("archive.retention:write")
|
||||
app.fake.refuseNext(503)
|
||||
const silent = await refusal(t.call.callByName("archive.document.list", t.slugs))
|
||||
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
|
||||
})
|
||||
|
||||
it("let a viewer read and refuse their writes before the service is called", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
|
||||
expect(await viewer.callByName("archive.document.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
|
||||
const before = app.fake.calls.length
|
||||
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
|
||||
for (const [name, input] of [
|
||||
[
|
||||
"archive.document.register",
|
||||
{ title: "No", retentionClass: "contracts", fileId: await app.storedFile(t), requestId: crypto.randomUUID() },
|
||||
],
|
||||
["archive.document.hold", { documentId, revision: "1", legalHold: true, requestId: crypto.randomUUID() }],
|
||||
["archive.document.dispose", { documentId, revision: "1", requestId: crypto.randomUUID() }],
|
||||
["archive.retention.create", { code: "no", keepFor: "P1Y", from: "created", requestId: crypto.randomUUID() }],
|
||||
] as const) {
|
||||
const refused = await refusal(viewer.callByName(name, { ...t.slugs, ...input }))
|
||||
expect(refused.problem.status, name).toBe(404)
|
||||
}
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
})
|
||||
|
||||
it("let an editor register and keep retention to an administrator", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const editor = await app.member(t, "editor@alpha.test", "member")
|
||||
const registered = await editor.callByName("archive.document.register", {
|
||||
...t.slugs,
|
||||
title: "Editor's document",
|
||||
retentionClass: "contracts",
|
||||
fileId: await app.storedFile(t, "d".repeat(64)),
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
expect(registered).toMatchObject({ revision: "1" })
|
||||
const refused = await refusal(
|
||||
editor.callByName("archive.document.hold", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
legalHold: true,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(refused.problem.status).toBe(404)
|
||||
})
|
||||
|
||||
it("need no scope beyond the ones `kit module:on archive` asks for", async () => {
|
||||
let now = new Date("2026-11-02T10:00:00Z").getTime()
|
||||
const app = archiveApp({
|
||||
clock: () => new Date(now),
|
||||
scopes: [
|
||||
"archive.documents:read",
|
||||
"archive.documents:write",
|
||||
"archive.holds:write",
|
||||
"archive.retention:read",
|
||||
"archive.retention:write",
|
||||
"archive.disposal:read",
|
||||
"archive.disposal:write",
|
||||
],
|
||||
})
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t, "short", "P1D")
|
||||
const { documentId } = await register(app, t, "Delivery note", "short")
|
||||
await t.call.callByName("archive.version.add", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
fileId: await app.storedFile(t, "b".repeat(64)),
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
await t.call.callByName("archive.document.list", t.slugs)
|
||||
await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
|
||||
await t.call.callByName("archive.retention.list", t.slugs)
|
||||
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
|
||||
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: true })
|
||||
await t.call.callByName("archive.document.hold", { ...request(), revision: "3", legalHold: false })
|
||||
now += 2 * 24 * 3600 * 1000
|
||||
await t.call.callByName("archive.disposal.list", t.slugs)
|
||||
await t.call.callByName("archive.document.dispose", { ...request(), revision: "4" })
|
||||
})
|
||||
|
||||
it("refuse another organization's file: a document cannot be made of a record its project does not hold", async () => {
|
||||
const app = archiveApp()
|
||||
const alpha = await app.tenant("alpha")
|
||||
const bravo = await app.tenant("bravo")
|
||||
await retention(bravo)
|
||||
const theirs = await app.storedFile(alpha)
|
||||
const before = app.fake.calls.length
|
||||
const refused = await refusal(
|
||||
bravo.call.callByName("archive.document.register", {
|
||||
...bravo.slugs,
|
||||
title: "Not mine",
|
||||
retentionClass: "contracts",
|
||||
fileId: theirs,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(refused.problem.status).toBe(404)
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
expect(app.fake.archive.documents.size).toBe(0)
|
||||
})
|
||||
|
||||
it("refuse a record of another platform organization: the token reaches only its own", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const elsewhere = sdkArchiveService({ client: app.client, orgSlug: "another-organization" })
|
||||
await expect(elsewhere.getDocument(documentId)).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("the archive module's tenant isolation", () => {
|
||||
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
|
||||
const app = archiveApp()
|
||||
const tenant = async (slug: string): Promise<IsolationTenant> => {
|
||||
const t = await app.tenant(slug)
|
||||
return {
|
||||
orgSlug: t.orgSlug,
|
||||
workspaceSlug: t.workspaceSlug,
|
||||
projectSlug: t.projectSlug,
|
||||
orgId: t.orgId,
|
||||
workspaceId: t.workspaceId,
|
||||
projectId: t.projectId,
|
||||
userId: t.owner.userId,
|
||||
email: `owner@${slug}.test`,
|
||||
extra: {},
|
||||
}
|
||||
}
|
||||
const a = await tenant("alpha")
|
||||
const b = await tenant("bravo")
|
||||
const result = await runIsolation({
|
||||
registry: app.registry,
|
||||
seed: async () => ({ a, b }),
|
||||
caller: (actor) => app.caller(actor),
|
||||
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
|
||||
fixtures: { ...tenancyIsolationFixtures(), ...archiveIsolationFixtures },
|
||||
alsoCheck: [...TENANCY_ALSO_CHECK],
|
||||
exempt: tenancyIsolationExempt,
|
||||
})
|
||||
expect(result.failures).toEqual([])
|
||||
for (const name of Object.keys(archiveIsolationFixtures)) expect(result.passed, name).toContain(name)
|
||||
expect(app.fake.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
// The pages and components, rendered the way the shell renders them, from the loader's data: the platform's
|
||||
// refusals are the page's own states, a control is offered to whoever may use it, every table has a
|
||||
// caption and scoped headers.
|
||||
|
||||
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
|
||||
import { filesModule } from "@conusai-kit/files"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactNode } from "react"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import { archiveModule } from "../module.js"
|
||||
import { archivePage } from "../ui/pages.js"
|
||||
|
||||
const catalogues = buildRegistry([tenancyModule, filesModule, archiveModule]).catalogues
|
||||
const i18n = createI18n("en", {
|
||||
en: { archive: catalogues.en?.archive ?? {}, app: { failed: "That did not work: {detail}" } },
|
||||
})
|
||||
const lithuanian = createI18n("lt", {
|
||||
lt: { archive: catalogues.lt?.archive ?? {}, app: { failed: "Nepavyko: {detail}" } },
|
||||
})
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
const VIEWER: readonly PermissionKey[] = ["archive.document.read"]
|
||||
const EDITOR: readonly PermissionKey[] = [...VIEWER, "archive.document.write"]
|
||||
const ADMIN: readonly PermissionKey[] = [...EDITOR, "archive.retention.manage"]
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = ADMIN) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
|
||||
{node}
|
||||
</PermissionsProvider>
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }) => {
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (segments: string[], answers: Record<string, unknown>, permissions?: readonly PermissionKey[]) => {
|
||||
const data = await archivePage.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: {},
|
||||
caller: callerOf(answers),
|
||||
i18n,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
const { View } = archivePage
|
||||
return inProvider(
|
||||
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
|
||||
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
/** Every label of a form names a control the page holds, and every hint is in its control's description. */
|
||||
const expectLabelled = (html: string, labels: number): void => {
|
||||
const targets = [...html.matchAll(/<label[^>]* for="([^"]+)"/g)].map((match) => match[1] ?? "")
|
||||
expect(targets).toHaveLength(labels)
|
||||
for (const id of targets) expect(html).toMatch(new RegExp(`<(?:input|select|button)[^>]* id="${escaped(id)}"`))
|
||||
for (const [, id = ""] of html.matchAll(/<p id="([^"]+)" data-slot="field-description"/g))
|
||||
expect(html).toMatch(new RegExp(`aria-describedby="(?:[^"]* )?${escaped(id)}[ "]`))
|
||||
}
|
||||
|
||||
const document = {
|
||||
documentId: "doc_1",
|
||||
title: "Signed contract",
|
||||
kind: "380",
|
||||
retentionClass: "contracts",
|
||||
legalHold: false,
|
||||
status: "active",
|
||||
disposed: false,
|
||||
revision: "2",
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
lastVersionAt: "2026-11-02T11:00:00.000Z",
|
||||
disposedAt: null,
|
||||
}
|
||||
|
||||
const version = {
|
||||
versionId: "dvr_1",
|
||||
sequence: 1,
|
||||
sha256: "a".repeat(64),
|
||||
mediaType: "application/pdf",
|
||||
size: "1234",
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
}
|
||||
|
||||
const contracts = { code: "contracts", keepFor: "P10Y", from: "created", createdAt: "2026-11-02T10:00:00.000Z" }
|
||||
|
||||
const listAnswers = {
|
||||
"archive.document.list": { rows: [document] },
|
||||
"archive.retention.list": { rows: [contracts] },
|
||||
}
|
||||
const documentAnswers = { "archive.document.get": { document, versions: [version] } }
|
||||
const retentionAnswers = {
|
||||
"archive.retention.list": { rows: [contracts] },
|
||||
"archive.disposal.list": { rows: [document] },
|
||||
}
|
||||
|
||||
describe("the archive page", () => {
|
||||
it("lists the documents, with the register form for whoever may add one", async () => {
|
||||
const html = await render([], listAnswers)
|
||||
expect(html).toMatch(/<h1\b[^>]*>Archive<\/h1>/)
|
||||
expect(html).toMatch(
|
||||
/<a\b(?=[^>]*href="\/acme\/ws\/p\/archive\/doc_1")(?=[^>]*aria-label="Open Signed contract")[^>]*>Signed contract<\/a>/,
|
||||
)
|
||||
expect(html).toMatch(/<table\b[^>]*aria-label="Documents"/)
|
||||
expect(html).toMatch(/<option\b[^>]*value="contracts"[^>]*>contracts<\/option>/)
|
||||
expect(html).toContain('type="file"')
|
||||
expectLabelled(html, 5)
|
||||
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
|
||||
expect(await render([], listAnswers, VIEWER)).not.toContain('type="file"')
|
||||
})
|
||||
|
||||
it("says a document cannot be registered before there is a retention class", async () => {
|
||||
const html = await render([], { ...listAnswers, "archive.retention.list": { rows: [] } })
|
||||
expect(html).toContain("Define a retention class first.")
|
||||
expect(html).not.toContain('type="file"')
|
||||
})
|
||||
|
||||
it("shows a document's versions, with the controls each role may use", async () => {
|
||||
const html = await render(["doc_1"], documentAnswers)
|
||||
expect(html).toContain("<h2>Signed contract</h2>")
|
||||
expect(html).toMatch(/<caption\b[^>]*>Versions<\/caption>/)
|
||||
expect(html).toMatch(/<th\b[^>]*scope="row"[^>]*>1<\/th><td\b[^>]*>application\/pdf<\/td><td\b[^>]*>1234<\/td>/)
|
||||
expect(html).toContain("Add a version")
|
||||
expectLabelled(html, 1)
|
||||
expect(html).toContain("Place a legal hold")
|
||||
expect(html).toContain("Dispose")
|
||||
|
||||
const editor = await render(["doc_1"], documentAnswers, EDITOR)
|
||||
expect(editor).toContain("Add a version")
|
||||
expect(editor).not.toContain("Place a legal hold")
|
||||
const viewer = await render(["doc_1"], documentAnswers, VIEWER)
|
||||
expect(viewer).not.toContain("Add a version")
|
||||
})
|
||||
|
||||
it("offers nothing on a disposed document", async () => {
|
||||
const disposed = { ...document, status: "disposed", disposed: true, disposedAt: "2026-12-01T10:00:00.000Z" }
|
||||
const html = await render(["doc_1"], { "archive.document.get": { document: disposed, versions: [version] } })
|
||||
expect(html).toContain("Disposed")
|
||||
expect(html).not.toContain("Add a version")
|
||||
expect(html).not.toContain("Place a legal hold")
|
||||
})
|
||||
|
||||
it("lists the retention classes and what is due, with the form for an administrator", async () => {
|
||||
const html = await render(["retention"], retentionAnswers)
|
||||
expect(html).toMatch(/<h1\b[^>]*>Retention<\/h1>/)
|
||||
expect(html).toMatch(
|
||||
/<th\b[^>]*scope="row"[^>]*>contracts<\/th><td\b[^>]*>P10Y<\/td><td\b[^>]*>When the document is registered<\/td>/,
|
||||
)
|
||||
expect(html).toMatch(/<caption\b[^>]*>Disposal due<\/caption>/)
|
||||
expect(html).toContain("New retention class")
|
||||
expectLabelled(html, 3)
|
||||
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
|
||||
expect(await render(["retention"], retentionAnswers, EDITOR)).not.toContain("New retention class")
|
||||
})
|
||||
|
||||
it.each([
|
||||
["service_not_enabled", "This service is not enabled for your organization."],
|
||||
["service_permission_missing", "This app is not allowed to use this service. Ask an administrator to grant it."],
|
||||
["business_unavailable", "The service did not answer. Try again later."],
|
||||
])("shows the typed state %s", async (code, text) => {
|
||||
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 503, code, detail: "why" })
|
||||
const html = await render([], { ...listAnswers, "archive.document.list": refused })
|
||||
expect(html).toContain(`data-state="${code}"`)
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).toContain(text)
|
||||
expect(html).not.toContain("<table")
|
||||
})
|
||||
|
||||
it("shows the same state in Lithuanian", async () => {
|
||||
const code = "service_not_enabled"
|
||||
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 404, code })
|
||||
const data = await archivePage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: callerOf({ ...listAnswers, "archive.document.list": refused }),
|
||||
i18n: lithuanian,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(data).toMatchObject({
|
||||
kind: "state",
|
||||
notice: { code, text: "Ši paslauga jūsų organizacijai neįjungta." },
|
||||
})
|
||||
})
|
||||
|
||||
it("leaves a document that is not there, and a path it does not have, to the shell", async () => {
|
||||
const missing = new AppError({ type: "/problems/not_found", title: "Not found", status: 404, code: "not_found" })
|
||||
await expect(render(["doc_2"], { "archive.document.get": missing })).rejects.toMatchObject({
|
||||
problem: { code: "not_found" },
|
||||
})
|
||||
await expect(render(["doc_1", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["retention", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["%2Fetc"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["%E0%A4%A"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.archive?.()).resolves.toBe(archivePage)
|
||||
})
|
||||
})
|
||||
|
||||
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
||||
vi.mock("../../inclusion.json", () => ({
|
||||
default: { schemaVersion: 1, selected: ["archive"], everAdopted: ["archive"] },
|
||||
}))
|
||||
@@ -0,0 +1,109 @@
|
||||
// The handlers of the module's procedures: each takes the service its kind may use and calls one use
|
||||
// case. No rule lives here.
|
||||
|
||||
import { implement } from "@conusai-kit/core"
|
||||
import { filesDeps } from "@conusai-kit/files"
|
||||
import {
|
||||
disposalList,
|
||||
documentDispose,
|
||||
documentGet,
|
||||
documentHold,
|
||||
documentList,
|
||||
documentRegister,
|
||||
retentionCreate,
|
||||
retentionList,
|
||||
versionAdd,
|
||||
} from "../contract/index.js"
|
||||
import {
|
||||
addVersion,
|
||||
createRetentionClass,
|
||||
disposeDocument,
|
||||
getDocument,
|
||||
listDisposalDue,
|
||||
listDocuments,
|
||||
listRetentionClasses,
|
||||
registerDocument,
|
||||
setLegalHold,
|
||||
} from "../core/archive.js"
|
||||
import { archiveServiceOf } from "./service.js"
|
||||
|
||||
const paged = (input: { after?: string | undefined; limit?: number | undefined }) => ({
|
||||
...(input.after === undefined ? {} : { after: input.after }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
})
|
||||
|
||||
export const documentListImpl = implement(documentList, ({ input, ctx }) =>
|
||||
listDocuments(archiveServiceOf(ctx.container, ctx.kind), {
|
||||
...(input.q === undefined ? {} : { q: input.q }),
|
||||
...paged(input),
|
||||
}),
|
||||
)
|
||||
|
||||
export const documentGetImpl = implement(documentGet, ({ input, ctx }) =>
|
||||
getDocument(archiveServiceOf(ctx.container, ctx.kind), input.documentId),
|
||||
)
|
||||
|
||||
export const documentRegisterImpl = implement(documentRegister, ({ input, ctx }) =>
|
||||
registerDocument(archiveServiceOf(ctx.container, "effect"), filesDeps(ctx.container), ctx.scope, {
|
||||
title: input.title,
|
||||
...(input.kind === undefined ? {} : { kind: input.kind }),
|
||||
retentionClass: input.retentionClass,
|
||||
fileId: input.fileId,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const versionAddImpl = implement(versionAdd, ({ input, ctx }) =>
|
||||
addVersion(archiveServiceOf(ctx.container, "effect"), filesDeps(ctx.container), ctx.scope, {
|
||||
documentId: input.documentId,
|
||||
revision: input.revision,
|
||||
fileId: input.fileId,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const documentHoldImpl = implement(documentHold, ({ input, ctx }) =>
|
||||
setLegalHold(archiveServiceOf(ctx.container, "effect"), ctx.scope, {
|
||||
documentId: input.documentId,
|
||||
revision: input.revision,
|
||||
legalHold: input.legalHold,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const documentDisposeImpl = implement(documentDispose, ({ input, ctx }) =>
|
||||
disposeDocument(archiveServiceOf(ctx.container, "effect"), ctx.scope, {
|
||||
documentId: input.documentId,
|
||||
revision: input.revision,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const retentionListImpl = implement(retentionList, ({ input, ctx }) =>
|
||||
listRetentionClasses(archiveServiceOf(ctx.container, ctx.kind), paged(input)),
|
||||
)
|
||||
|
||||
export const retentionCreateImpl = implement(retentionCreate, ({ input, ctx }) =>
|
||||
createRetentionClass(archiveServiceOf(ctx.container, "effect"), ctx.scope, {
|
||||
code: input.code,
|
||||
keepFor: input.keepFor,
|
||||
from: input.from,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const disposalListImpl = implement(disposalList, ({ input, ctx }) =>
|
||||
listDisposalDue(archiveServiceOf(ctx.container, ctx.kind), paged(input)),
|
||||
)
|
||||
|
||||
export const ARCHIVE_PROCEDURES = [
|
||||
documentListImpl,
|
||||
documentGetImpl,
|
||||
documentRegisterImpl,
|
||||
versionAddImpl,
|
||||
documentHoldImpl,
|
||||
documentDisposeImpl,
|
||||
retentionListImpl,
|
||||
retentionCreateImpl,
|
||||
disposalListImpl,
|
||||
]
|
||||
@@ -0,0 +1,204 @@
|
||||
// Generated SDK adapter. Production dispatch requires a server-prepared request binding.
|
||||
|
||||
import { businessAppError, type Client, requestBusinessBinding, toBusinessError } from "@conusai-kit/business"
|
||||
import type { Container, ProcedureKind } from "@conusai-kit/core"
|
||||
import {
|
||||
archiveAddVersion,
|
||||
archiveCreateRetentionClass,
|
||||
archiveDisposeDocument,
|
||||
archiveFindDocuments,
|
||||
archiveGetDocument,
|
||||
archiveListDisposalDue,
|
||||
archiveListRetentionClasses,
|
||||
archiveListVersions,
|
||||
archiveRegisterDocument,
|
||||
archiveSetLegalHold,
|
||||
type Document,
|
||||
type DocumentVersion,
|
||||
type Receipt,
|
||||
type RetentionClass,
|
||||
type VersionInput,
|
||||
} from "@conusai-sdk/archive"
|
||||
import { createClient } from "@conusai-sdk/archive/client"
|
||||
import type { DocumentReceipt, DocumentView, RetentionClassView, VersionFacts, VersionView } from "../contract/index.js"
|
||||
import { ARCHIVE_SERVICE, type ArchiveReads, type ArchiveService, archiveReadsOf } from "../core/ports.js"
|
||||
|
||||
const documentView = (document: Document): DocumentView => ({
|
||||
documentId: document.id,
|
||||
title: document.title,
|
||||
kind: document.kind ?? null,
|
||||
retentionClass: document.retention_class,
|
||||
legalHold: document.legal_hold,
|
||||
status: document.status as DocumentView["status"],
|
||||
disposed: document.status !== "active" || Boolean(document.erased_at),
|
||||
revision: String(document.revision),
|
||||
createdAt: document.created_at,
|
||||
lastVersionAt: document.last_version_at,
|
||||
disposedAt: document.disposed_at ?? null,
|
||||
})
|
||||
|
||||
const versionView = (version: DocumentVersion): VersionView => ({
|
||||
versionId: version.id,
|
||||
sequence: Number(version.sequence),
|
||||
sha256: version.sha256,
|
||||
mediaType: version.media_type,
|
||||
size: String(version.size),
|
||||
createdAt: version.created_at,
|
||||
})
|
||||
|
||||
const retentionView = (retention: RetentionClass): RetentionClassView => ({
|
||||
code: retention.code,
|
||||
keepFor: retention.keep_for,
|
||||
from: retention.from as RetentionClassView["from"],
|
||||
createdAt: retention.created_at,
|
||||
})
|
||||
|
||||
const versionInput = (version: VersionFacts): VersionInput => ({
|
||||
sha256: version.sha256,
|
||||
media_type: version.mediaType,
|
||||
size: BigInt(version.size),
|
||||
blob_ref: version.blobRef,
|
||||
})
|
||||
|
||||
const receiptView = (receipt: Receipt): DocumentReceipt => ({
|
||||
documentId: receipt.id,
|
||||
revision: String(receipt.revision ?? 0),
|
||||
})
|
||||
|
||||
/** What a generated operation answered: its data, or the kit's typed app error for the refusal. */
|
||||
async function answer<T>(
|
||||
call: Promise<{ data?: T; error?: unknown; response?: Response }>,
|
||||
): Promise<Exclude<T, undefined>> {
|
||||
let result: { data?: T; error?: unknown; response?: Response }
|
||||
try {
|
||||
result = await call
|
||||
} catch (error) {
|
||||
throw businessAppError(toBusinessError(error, undefined))
|
||||
}
|
||||
if (result.error !== undefined || result.data === undefined) {
|
||||
throw businessAppError(toBusinessError(result.error, result.response))
|
||||
}
|
||||
return result.data as Exclude<T, undefined>
|
||||
}
|
||||
|
||||
const pageQuery = (query: { after?: string; limit: number }) => ({
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: BigInt(query.limit),
|
||||
})
|
||||
|
||||
const pageOf = <S, T>(page: { data: S[]; next_after?: string | null }, view: (row: S) => T) => ({
|
||||
rows: page.data.map(view),
|
||||
...(page.next_after ? { after: page.next_after } : {}),
|
||||
})
|
||||
|
||||
/** The port over one client, in one organization. */
|
||||
export function sdkArchiveService(binding: { client: Client; orgSlug: string }): ArchiveService {
|
||||
const { client } = binding
|
||||
const org = { org_slug: binding.orgSlug }
|
||||
return {
|
||||
async listDocuments(query) {
|
||||
const page = await answer(
|
||||
archiveFindDocuments({
|
||||
client,
|
||||
path: org,
|
||||
query: { ...(query.q === undefined ? {} : { q: query.q }), ...pageQuery(query) },
|
||||
}),
|
||||
)
|
||||
return pageOf(page, documentView)
|
||||
},
|
||||
async getDocument(documentId) {
|
||||
return documentView(await answer(archiveGetDocument({ client, path: { ...org, document_id: documentId } })))
|
||||
},
|
||||
async listVersions(documentId, query) {
|
||||
const page = await answer(
|
||||
archiveListVersions({ client, path: { ...org, document_id: documentId }, query: pageQuery(query) }),
|
||||
)
|
||||
return pageOf(page, versionView)
|
||||
},
|
||||
async listRetentionClasses(query) {
|
||||
const page = await answer(archiveListRetentionClasses({ client, path: org, query: pageQuery(query) }))
|
||||
return pageOf(page, retentionView)
|
||||
},
|
||||
async listDisposalDue(query) {
|
||||
const page = await answer(archiveListDisposalDue({ client, path: org, query: pageQuery(query) }))
|
||||
return pageOf(page, documentView)
|
||||
},
|
||||
async registerDocument(input) {
|
||||
const receipt = await answer(
|
||||
archiveRegisterDocument({
|
||||
client,
|
||||
path: org,
|
||||
body: {
|
||||
title: input.title,
|
||||
...(input.kind === undefined ? {} : { kind: input.kind }),
|
||||
retention_class: input.retentionClass,
|
||||
version: versionInput(input.version),
|
||||
idempotency_key: input.key,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return receiptView(receipt)
|
||||
},
|
||||
async addVersion(input) {
|
||||
const receipt = await answer(
|
||||
archiveAddVersion({
|
||||
client,
|
||||
path: { ...org, document_id: input.documentId },
|
||||
body: {
|
||||
revision: BigInt(input.revision),
|
||||
version: versionInput(input.version),
|
||||
idempotency_key: input.key,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return receiptView(receipt)
|
||||
},
|
||||
async setLegalHold(input) {
|
||||
const receipt = await answer(
|
||||
archiveSetLegalHold({
|
||||
client,
|
||||
path: { ...org, document_id: input.documentId },
|
||||
body: { revision: BigInt(input.revision), legal_hold: input.legalHold, idempotency_key: input.key },
|
||||
}),
|
||||
)
|
||||
return receiptView(receipt)
|
||||
},
|
||||
async disposeDocument(input) {
|
||||
const receipt = await answer(
|
||||
archiveDisposeDocument({
|
||||
client,
|
||||
path: { ...org, document_id: input.documentId },
|
||||
body: { revision: BigInt(input.revision), idempotency_key: input.key },
|
||||
}),
|
||||
)
|
||||
return receiptView(receipt)
|
||||
},
|
||||
async createRetentionClass(input) {
|
||||
await answer(
|
||||
archiveCreateRetentionClass({
|
||||
client,
|
||||
path: org,
|
||||
body: { code: input.code, keep_for: input.keepFor, from: input.from, idempotency_key: input.key },
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The service a handler of `kind` may use: the bound one (the token's view for the kind applies), or
|
||||
* the current verified request's, narrowed to its reads for a `read` or `write` handler as the token would.
|
||||
*/
|
||||
export function archiveServiceOf(container: Container, kind: "effect"): ArchiveService
|
||||
export function archiveServiceOf(container: Container, kind: ProcedureKind): ArchiveReads
|
||||
export function archiveServiceOf(container: Container, kind: ProcedureKind): ArchiveReads {
|
||||
if (container.has(ARCHIVE_SERVICE)) return container.resolve(ARCHIVE_SERVICE) as ArchiveReads
|
||||
let service: ArchiveService
|
||||
try {
|
||||
const binding = requestBusinessBinding(container)
|
||||
service = sdkArchiveService({ client: binding.client(createClient), orgSlug: binding.orgSlug })
|
||||
} catch (error) {
|
||||
throw businessAppError(error)
|
||||
}
|
||||
return kind === "effect" ? service : archiveReadsOf(service)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// The module's names, in a file with no import: a browser bundle can take them without the schemas.
|
||||
|
||||
/** The platform service this module is a client of (B-wave contract §3.14). */
|
||||
export const ARCHIVE_SERVICE_NAME = "archive"
|
||||
|
||||
/** The app permissions of the module. */
|
||||
export const ARCHIVE_PERMISSIONS = {
|
||||
read: "archive.document.read",
|
||||
write: "archive.document.write",
|
||||
manage: "archive.retention.manage",
|
||||
} as const
|
||||
|
||||
/** What a retention period counts from, as the service names it. */
|
||||
export const RETENTION_BASES = ["created", "last_version", "event"] as const
|
||||
|
||||
/** The most rows one page of a list asks the service for. */
|
||||
export const PAGE_LIMIT = 50
|
||||
@@ -0,0 +1,28 @@
|
||||
// The public API of the archive module: what a page, another module and the OpenAPI document may import.
|
||||
// Nothing here reaches core/, adapters/ or a service client.
|
||||
|
||||
export {
|
||||
ARCHIVE_PERMISSIONS,
|
||||
ARCHIVE_SERVICE_NAME,
|
||||
PAGE_LIMIT,
|
||||
RETENTION_BASES,
|
||||
} from "./constants.js"
|
||||
export {
|
||||
type DocumentReceipt,
|
||||
type DocumentView,
|
||||
disposalList,
|
||||
documentDispose,
|
||||
documentGet,
|
||||
documentHold,
|
||||
documentList,
|
||||
documentRegister,
|
||||
documentViewSchema,
|
||||
type RetentionClassView,
|
||||
retentionClassViewSchema,
|
||||
retentionCreate,
|
||||
retentionList,
|
||||
type VersionFacts,
|
||||
type VersionView,
|
||||
versionAdd,
|
||||
versionViewSchema,
|
||||
} from "./procedures.js"
|
||||
@@ -0,0 +1,163 @@
|
||||
// The procedure contracts of the archive module: schemas and names, no handler and no port.
|
||||
//
|
||||
// The module is a client of the platform's Archive service (B-wave contract §3.14): documents, their
|
||||
// versions and their retention live there, in the deployment's organization, and the bytes in the
|
||||
// platform's Blob store. A read reads; a change is an `effect` (it calls an external service and writes
|
||||
// nothing of this app's own). A version is made from a file the project holds, so a procedure names a
|
||||
// `fileId`, never a hash or a Blob reference of the caller's. The service's int64 fields are their digits
|
||||
// here, so what a page loads is JSON.
|
||||
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { ARCHIVE_PERMISSIONS, RETENTION_BASES } from "./constants.js"
|
||||
|
||||
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
|
||||
const page = { after: z.string().max(128).optional(), limit: z.number().int().min(1).max(200).optional() }
|
||||
/** What a form generates once per submission, so a retried request applies once. */
|
||||
const requestId = z.string().uuid()
|
||||
const documentId = z.string().min(1).max(64)
|
||||
const fileId = z.string().uuid()
|
||||
/** A revision as its digits: the service's are 64-bit. */
|
||||
const revision = z.string().regex(/^\d{1,19}$/)
|
||||
|
||||
export const documentViewSchema = z.object({
|
||||
documentId: z.string(),
|
||||
title: z.string(),
|
||||
/** An optional UNTDID 1001 document code. */
|
||||
kind: z.string().nullable(),
|
||||
retentionClass: z.string(),
|
||||
legalHold: z.boolean(),
|
||||
status: z.enum(["active", "disposed"]),
|
||||
/** Whether the document is past changing: disposed, or erased. */
|
||||
disposed: z.boolean(),
|
||||
revision,
|
||||
createdAt: z.string(),
|
||||
lastVersionAt: z.string(),
|
||||
disposedAt: z.string().nullable(),
|
||||
})
|
||||
export type DocumentView = z.output<typeof documentViewSchema>
|
||||
|
||||
export const versionViewSchema = z.object({
|
||||
versionId: z.string(),
|
||||
sequence: z.number().int(),
|
||||
sha256: z.string(),
|
||||
mediaType: z.string(),
|
||||
/** Bytes, as digits. */
|
||||
size: z.string(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
export type VersionView = z.output<typeof versionViewSchema>
|
||||
|
||||
export const retentionClassViewSchema = z.object({
|
||||
code: z.string(),
|
||||
keepFor: z.string(),
|
||||
from: z.enum(RETENTION_BASES),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
export type RetentionClassView = z.output<typeof retentionClassViewSchema>
|
||||
|
||||
/** A version's immutable facts, as the use cases take them from the stored file. */
|
||||
export interface VersionFacts {
|
||||
readonly sha256: string
|
||||
readonly mediaType: string
|
||||
readonly size: number
|
||||
readonly blobRef: string
|
||||
}
|
||||
|
||||
const receipt = z.object({ documentId: z.string(), revision })
|
||||
export type DocumentReceipt = z.output<typeof receipt>
|
||||
|
||||
const documentPage = z.object({ rows: z.array(documentViewSchema), after: z.string().optional() })
|
||||
|
||||
export const documentList = procedure("archive.document.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, q: z.string().max(200).optional(), ...page }))
|
||||
.output(documentPage)
|
||||
.contract()
|
||||
|
||||
export const documentGet = procedure("archive.document.get")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, documentId }))
|
||||
.output(z.object({ document: documentViewSchema, versions: z.array(versionViewSchema) }))
|
||||
.contract()
|
||||
|
||||
export const documentRegister = procedure("archive.document.register")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.write)
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
title: z.string().trim().min(1).max(200),
|
||||
kind: z
|
||||
.string()
|
||||
.regex(/^\d{1,4}$/)
|
||||
.optional(),
|
||||
retentionClass: z.string().min(1).max(64),
|
||||
fileId,
|
||||
requestId,
|
||||
}),
|
||||
)
|
||||
.output(receipt)
|
||||
.contract()
|
||||
|
||||
export const versionAdd = procedure("archive.version.add")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.write)
|
||||
.input(z.object({ ...inProject, documentId, revision, fileId, requestId }))
|
||||
.output(receipt)
|
||||
.contract()
|
||||
|
||||
export const documentHold = procedure("archive.document.hold")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.manage)
|
||||
.input(z.object({ ...inProject, documentId, revision, legalHold: z.boolean(), requestId }))
|
||||
.output(receipt)
|
||||
.contract()
|
||||
|
||||
export const documentDispose = procedure("archive.document.dispose")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.manage)
|
||||
.input(z.object({ ...inProject, documentId, revision, requestId }))
|
||||
.output(receipt)
|
||||
.contract()
|
||||
|
||||
export const retentionList = procedure("archive.retention.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, ...page }))
|
||||
.output(z.object({ rows: z.array(retentionClassViewSchema), after: z.string().optional() }))
|
||||
.contract()
|
||||
|
||||
export const retentionCreate = procedure("archive.retention.create")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.manage)
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
code: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
|
||||
/** An ISO 8601 duration such as P10Y; the service reads it. */
|
||||
keepFor: z.string().min(2).max(64),
|
||||
from: z.enum(RETENTION_BASES),
|
||||
requestId,
|
||||
}),
|
||||
)
|
||||
.output(z.object({ code: z.string() }))
|
||||
.contract()
|
||||
|
||||
export const disposalList = procedure("archive.disposal.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(ARCHIVE_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, ...page }))
|
||||
.output(documentPage)
|
||||
.contract()
|
||||
@@ -0,0 +1,139 @@
|
||||
// What the archive module does, as pure TypeScript over the port in ports.ts.
|
||||
//
|
||||
// Documents are the organization's: every project of the app sees the same ones (a multi-tenant app keeps
|
||||
// all its tenants' documents in its deployment's one organization; partitioning by app tenant is an open
|
||||
// owner decision, B-wave contract §11.4). A version is made from a file the project holds: the files
|
||||
// module's read is the membership check, so another project's or organization's file is simply not there,
|
||||
// and the facts a version records are the ones the store measured, never the caller's.
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { type FilesDeps, getFileStorage } from "@conusai-kit/files"
|
||||
import {
|
||||
type DocumentReceipt,
|
||||
type DocumentView,
|
||||
PAGE_LIMIT,
|
||||
type RetentionClassView,
|
||||
type VersionFacts,
|
||||
type VersionView,
|
||||
} from "../contract/index.js"
|
||||
import type { ArchiveReads, ArchiveService, Page } from "./ports.js"
|
||||
|
||||
/** The service's idempotency key of a request: one project's request id never answers another's. */
|
||||
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
|
||||
`app-project:${scope.projectId}:${requestId}`
|
||||
|
||||
const paged = (query: { after?: string; limit?: number }) => ({
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit ?? PAGE_LIMIT,
|
||||
})
|
||||
|
||||
/** The facts of a version made from a stored file. The reference names the file, and nothing else of it. */
|
||||
export async function versionFacts(deps: FilesDeps, scope: ProjectScope, fileId: string): Promise<VersionFacts> {
|
||||
const { file } = await getFileStorage(deps, scope, fileId)
|
||||
return {
|
||||
sha256: file.sha256,
|
||||
mediaType: file.contentType,
|
||||
size: file.size,
|
||||
blobRef: `conusai://blob/files/${file.fileId}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function listDocuments(
|
||||
reads: ArchiveReads,
|
||||
query: { q?: string; after?: string; limit?: number },
|
||||
): Promise<Page<DocumentView>> {
|
||||
const q = query.q?.trim()
|
||||
return reads.listDocuments({ ...(q ? { q } : {}), ...paged(query) })
|
||||
}
|
||||
|
||||
export async function getDocument(
|
||||
reads: ArchiveReads,
|
||||
documentId: string,
|
||||
): Promise<{ document: DocumentView; versions: VersionView[] }> {
|
||||
const document = await reads.getDocument(documentId)
|
||||
const versions = await reads.listVersions(documentId, { limit: PAGE_LIMIT })
|
||||
return { document, versions: versions.rows }
|
||||
}
|
||||
|
||||
export async function registerDocument(
|
||||
service: ArchiveService,
|
||||
files: FilesDeps,
|
||||
scope: ProjectScope,
|
||||
input: { title: string; kind?: string; retentionClass: string; fileId: string; requestId: string },
|
||||
): Promise<DocumentReceipt> {
|
||||
const kind = input.kind?.trim()
|
||||
return service.registerDocument({
|
||||
title: input.title.trim(),
|
||||
...(kind ? { kind } : {}),
|
||||
retentionClass: input.retentionClass,
|
||||
version: await versionFacts(files, scope, input.fileId),
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
|
||||
export async function addVersion(
|
||||
service: ArchiveService,
|
||||
files: FilesDeps,
|
||||
scope: ProjectScope,
|
||||
input: { documentId: string; revision: string; fileId: string; requestId: string },
|
||||
): Promise<DocumentReceipt> {
|
||||
return service.addVersion({
|
||||
documentId: input.documentId,
|
||||
revision: input.revision,
|
||||
version: await versionFacts(files, scope, input.fileId),
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
|
||||
export function setLegalHold(
|
||||
service: ArchiveService,
|
||||
scope: ProjectScope,
|
||||
input: { documentId: string; revision: string; legalHold: boolean; requestId: string },
|
||||
): Promise<DocumentReceipt> {
|
||||
return service.setLegalHold({
|
||||
documentId: input.documentId,
|
||||
revision: input.revision,
|
||||
legalHold: input.legalHold,
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
|
||||
export function disposeDocument(
|
||||
service: ArchiveService,
|
||||
scope: ProjectScope,
|
||||
input: { documentId: string; revision: string; requestId: string },
|
||||
): Promise<DocumentReceipt> {
|
||||
return service.disposeDocument({
|
||||
documentId: input.documentId,
|
||||
revision: input.revision,
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
|
||||
export function listRetentionClasses(
|
||||
reads: ArchiveReads,
|
||||
query: { after?: string; limit?: number },
|
||||
): Promise<Page<RetentionClassView>> {
|
||||
return reads.listRetentionClasses(paged(query))
|
||||
}
|
||||
|
||||
export async function createRetentionClass(
|
||||
service: ArchiveService,
|
||||
scope: ProjectScope,
|
||||
input: { code: string; keepFor: string; from: RetentionClassView["from"]; requestId: string },
|
||||
): Promise<{ code: string }> {
|
||||
await service.createRetentionClass({
|
||||
code: input.code,
|
||||
keepFor: input.keepFor.trim(),
|
||||
from: input.from,
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
return { code: input.code }
|
||||
}
|
||||
|
||||
export function listDisposalDue(
|
||||
reads: ArchiveReads,
|
||||
query: { after?: string; limit?: number },
|
||||
): Promise<Page<DocumentView>> {
|
||||
return reads.listDisposalDue(paged(query))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// The port the archive module's use cases are written against: the platform's Archive service, in this
|
||||
// module's own words. adapters/service.ts is the one place that knows `@conusai-sdk/archive` exists, and
|
||||
// it turns every refusal into the kit's typed app error (`businessAppError`) before core sees it.
|
||||
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { DocumentReceipt, DocumentView, RetentionClassView, VersionFacts, VersionView } from "../contract/index.js"
|
||||
|
||||
export interface Page<T> {
|
||||
readonly rows: T[]
|
||||
readonly after?: string
|
||||
}
|
||||
|
||||
/** What a `read` procedure may ask the service: nothing that changes a row. */
|
||||
export interface ArchiveReads {
|
||||
listDocuments(query: { q?: string; after?: string; limit: number }): Promise<Page<DocumentView>>
|
||||
getDocument(documentId: string): Promise<DocumentView>
|
||||
listVersions(documentId: string, query: { after?: string; limit: number }): Promise<Page<VersionView>>
|
||||
listRetentionClasses(query: { after?: string; limit: number }): Promise<Page<RetentionClassView>>
|
||||
listDisposalDue(query: { after?: string; limit: number }): Promise<Page<DocumentView>>
|
||||
}
|
||||
|
||||
export interface ArchiveService extends ArchiveReads {
|
||||
registerDocument(input: {
|
||||
title: string
|
||||
kind?: string
|
||||
retentionClass: string
|
||||
version: VersionFacts
|
||||
key: string
|
||||
}): Promise<DocumentReceipt>
|
||||
addVersion(input: {
|
||||
documentId: string
|
||||
revision: string
|
||||
version: VersionFacts
|
||||
key: string
|
||||
}): Promise<DocumentReceipt>
|
||||
setLegalHold(input: {
|
||||
documentId: string
|
||||
revision: string
|
||||
legalHold: boolean
|
||||
key: string
|
||||
}): Promise<DocumentReceipt>
|
||||
disposeDocument(input: { documentId: string; revision: string; key: string }): Promise<DocumentReceipt>
|
||||
createRetentionClass(input: {
|
||||
code: string
|
||||
keepFor: string
|
||||
from: RetentionClassView["from"]
|
||||
key: string
|
||||
}): Promise<void>
|
||||
}
|
||||
|
||||
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
|
||||
export const archiveReadsOf = (service: ArchiveReads): ArchiveReads => ({
|
||||
listDocuments: (query) => service.listDocuments(query),
|
||||
getDocument: (documentId) => service.getDocument(documentId),
|
||||
listVersions: (documentId, query) => service.listVersions(documentId, query),
|
||||
listRetentionClasses: (query) => service.listRetentionClasses(query),
|
||||
listDisposalDue: (query) => service.listDisposalDue(query),
|
||||
})
|
||||
|
||||
/**
|
||||
* Bound by a composition root or a test that brings its own client (another organization, the
|
||||
* business-services fake). With nothing bound, the module calls this deployment's service.
|
||||
*/
|
||||
export const ARCHIVE_SERVICE = token<ArchiveService>("archive.service", {
|
||||
views: { read: archiveReadsOf, write: archiveReadsOf },
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// English messages of the archive module. Every string a page of this module shows is a key here and in
|
||||
// lt.ts; `__tests__/i18n.test.ts` fails when the two differ.
|
||||
|
||||
export const en = {
|
||||
title: "Archive",
|
||||
intro:
|
||||
"Documents your organization keeps, with their immutable versions and how long they are kept, from the platform's Archive service. They are your organization's, shared by every project.",
|
||||
nav: {
|
||||
documents: "All documents",
|
||||
retention: "Retention",
|
||||
},
|
||||
statuses: {
|
||||
active: "Active",
|
||||
disposed: "Disposed",
|
||||
},
|
||||
documents: {
|
||||
caption: "Documents",
|
||||
empty: "No documents yet.",
|
||||
title: "Title",
|
||||
retentionClass: "Retention class",
|
||||
status: "Status",
|
||||
created: "Registered",
|
||||
open: "Open {title}",
|
||||
search: "Find a document",
|
||||
searchSubmit: "Find",
|
||||
more: "Show more",
|
||||
},
|
||||
register: {
|
||||
heading: "Register a document",
|
||||
title: "Title",
|
||||
kind: "Document code",
|
||||
kindHint: "Optional: a UNTDID 1001 code of up to four digits.",
|
||||
retentionClass: "Retention class",
|
||||
file: "File",
|
||||
submit: "Register",
|
||||
busy: "Registering",
|
||||
noClass: "Define a retention class first.",
|
||||
},
|
||||
document: {
|
||||
back: "All documents",
|
||||
retentionClass: "Retention class",
|
||||
status: "Status",
|
||||
revision: "Revision",
|
||||
created: "Registered",
|
||||
hold: "Legal hold",
|
||||
disposedAt: "Disposed on",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
},
|
||||
versions: {
|
||||
title: "Versions",
|
||||
count: "{count, plural, one {# version} other {# versions}}",
|
||||
caption: "Versions",
|
||||
sequence: "Version",
|
||||
mediaType: "Type",
|
||||
size: "Bytes",
|
||||
hash: "SHA-256",
|
||||
created: "Added",
|
||||
},
|
||||
versionForm: {
|
||||
heading: "Add a version",
|
||||
file: "File",
|
||||
submit: "Add version",
|
||||
busy: "Adding",
|
||||
},
|
||||
actions: {
|
||||
heading: "Retention",
|
||||
hold: "Place a legal hold",
|
||||
release: "Lift the legal hold",
|
||||
dispose: "Dispose",
|
||||
disposeConfirm: "Dispose of this document? This cannot be undone.",
|
||||
busy: "Working",
|
||||
},
|
||||
retention: {
|
||||
title: "Retention",
|
||||
intro:
|
||||
"A retention class says how long a document is kept and from when. It never changes: a new policy is a new class.",
|
||||
caption: "Retention classes",
|
||||
empty: "No retention classes yet.",
|
||||
code: "Code",
|
||||
keepFor: "Kept for",
|
||||
from: "Counted from",
|
||||
bases: {
|
||||
created: "When the document is registered",
|
||||
last_version: "The last version",
|
||||
event: "An event you record",
|
||||
},
|
||||
dueCaption: "Disposal due",
|
||||
dueEmpty: "Nothing is due for disposal.",
|
||||
},
|
||||
retentionForm: {
|
||||
heading: "New retention class",
|
||||
code: "Code",
|
||||
keepFor: "Kept for",
|
||||
keepForHint: "An ISO 8601 duration, such as P10Y for ten years or P30D for thirty days.",
|
||||
from: "Counted from",
|
||||
submit: "Add class",
|
||||
busy: "Adding",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The archive module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
|
||||
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,104 @@
|
||||
// Lietuviški archive modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
|
||||
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Archyvas",
|
||||
intro:
|
||||
"Dokumentai, kuriuos saugo jūsų organizacija, su nekintamomis versijomis ir saugojimo terminais, iš platformos archyvo paslaugos. Jie priklauso jūsų organizacijai ir yra bendri visiems projektams.",
|
||||
nav: {
|
||||
documents: "Visi dokumentai",
|
||||
retention: "Saugojimas",
|
||||
},
|
||||
statuses: {
|
||||
active: "Aktyvus",
|
||||
disposed: "Sunaikintas",
|
||||
},
|
||||
documents: {
|
||||
caption: "Dokumentai",
|
||||
empty: "Dokumentų dar nėra.",
|
||||
title: "Pavadinimas",
|
||||
retentionClass: "Saugojimo klasė",
|
||||
status: "Būsena",
|
||||
created: "Užregistruota",
|
||||
open: "Atidaryti: {title}",
|
||||
search: "Rasti dokumentą",
|
||||
searchSubmit: "Ieškoti",
|
||||
more: "Rodyti daugiau",
|
||||
},
|
||||
register: {
|
||||
heading: "Registruoti dokumentą",
|
||||
title: "Pavadinimas",
|
||||
kind: "Dokumento kodas",
|
||||
kindHint: "Neprivaloma: UNTDID 1001 kodas iki keturių skaitmenų.",
|
||||
retentionClass: "Saugojimo klasė",
|
||||
file: "Failas",
|
||||
submit: "Registruoti",
|
||||
busy: "Registruojama",
|
||||
noClass: "Pirmiausia apibrėžkite saugojimo klasę.",
|
||||
},
|
||||
document: {
|
||||
back: "Visi dokumentai",
|
||||
retentionClass: "Saugojimo klasė",
|
||||
status: "Būsena",
|
||||
revision: "Peržiūra",
|
||||
created: "Užregistruota",
|
||||
hold: "Teisinis sulaikymas",
|
||||
disposedAt: "Sunaikinta",
|
||||
yes: "Taip",
|
||||
no: "Ne",
|
||||
},
|
||||
versions: {
|
||||
title: "Versijos",
|
||||
count: "{count, plural, one {# versija} few {# versijos} other {# versijų}}",
|
||||
caption: "Versijos",
|
||||
sequence: "Versija",
|
||||
mediaType: "Tipas",
|
||||
size: "Baitai",
|
||||
hash: "SHA-256",
|
||||
created: "Pridėta",
|
||||
},
|
||||
versionForm: {
|
||||
heading: "Pridėti versiją",
|
||||
file: "Failas",
|
||||
submit: "Pridėti versiją",
|
||||
busy: "Pridedama",
|
||||
},
|
||||
actions: {
|
||||
heading: "Saugojimas",
|
||||
hold: "Taikyti teisinį sulaikymą",
|
||||
release: "Panaikinti teisinį sulaikymą",
|
||||
dispose: "Sunaikinti",
|
||||
disposeConfirm: "Sunaikinti šį dokumentą? To atšaukti negalima.",
|
||||
busy: "Vykdoma",
|
||||
},
|
||||
retention: {
|
||||
title: "Saugojimas",
|
||||
intro:
|
||||
"Saugojimo klasė nurodo, kiek laiko dokumentas saugomas ir nuo kada. Ji nesikeičia: nauja politika yra nauja klasė.",
|
||||
caption: "Saugojimo klasės",
|
||||
empty: "Saugojimo klasių dar nėra.",
|
||||
code: "Kodas",
|
||||
keepFor: "Saugoma",
|
||||
from: "Skaičiuojama nuo",
|
||||
bases: {
|
||||
created: "Dokumento registravimo",
|
||||
last_version: "Paskutinės versijos",
|
||||
event: "Jūsų įrašyto įvykio",
|
||||
},
|
||||
dueCaption: "Laukia sunaikinimo",
|
||||
dueEmpty: "Nieko nereikia sunaikinti.",
|
||||
},
|
||||
retentionForm: {
|
||||
heading: "Nauja saugojimo klasė",
|
||||
code: "Kodas",
|
||||
keepFor: "Saugoma",
|
||||
keepForHint: "ISO 8601 trukmė, pvz., P10Y – dešimt metų, P30D – trisdešimt dienų.",
|
||||
from: "Skaičiuojama nuo",
|
||||
submit: "Pridėti klasę",
|
||||
busy: "Pridedama",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// The archive module's public entry: its manifest, its contract, and the port an app may bind (a test's
|
||||
// fake, or a client for another organization). A page imports `./ui`.
|
||||
|
||||
export { sdkArchiveService } from "./adapters/service.js"
|
||||
export * from "./contract/index.js"
|
||||
export { ARCHIVE_SERVICE, type ArchiveReads, type ArchiveService } from "./core/ports.js"
|
||||
export { archiveModule } from "./module.js"
|
||||
@@ -0,0 +1,56 @@
|
||||
// The archive module's manifest: a client of the platform's Archive business service (B-wave contract
|
||||
// §3.14, docs/business-services.md). It keeps no table of its own -- documents, their immutable versions
|
||||
// and their retention are the service's, in the deployment's organization, and the bytes stay in the
|
||||
// platform's Blob store through the files module -- so it has procedures, permissions, pages and
|
||||
// messages, and no repository, migration, job or event.
|
||||
//
|
||||
// The template ships it off: `kit module:on archive` adds it to the list in modules/index.ts and asks the
|
||||
// deployment token for the service scopes it needs (`archive.documents:read|write`,
|
||||
// `archive.holds:write`, `archive.retention:read|write`, `archive.disposal:read|write`) in conusai.json.
|
||||
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { ARCHIVE_PROCEDURES } from "./adapters/procedures.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
export const archiveModule = defineModule({
|
||||
id: "archive",
|
||||
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy", "files"],
|
||||
permissions: {
|
||||
"archive.document.read": "viewer",
|
||||
"archive.document.write": "editor",
|
||||
// A hold, a disposal and a retention policy change what the organization keeps: an administrative act.
|
||||
"archive.retention.manage": "admin",
|
||||
},
|
||||
procedures: ARCHIVE_PROCEDURES,
|
||||
serviceRequirements: [
|
||||
{
|
||||
transport: "business",
|
||||
service: "archive",
|
||||
apiRange: "^1.1.0",
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
operations: {
|
||||
"archive.document.list": ["archive.documents:read"],
|
||||
"archive.document.get": ["archive.documents:read"],
|
||||
"archive.document.register": ["archive.documents:write"],
|
||||
"archive.version.add": ["archive.documents:write"],
|
||||
"archive.document.hold": ["archive.holds:write"],
|
||||
"archive.document.dispose": ["archive.disposal:write"],
|
||||
"archive.retention.list": ["archive.retention:read"],
|
||||
"archive.retention.create": ["archive.retention:write"],
|
||||
"archive.disposal.list": ["archive.disposal:read"],
|
||||
},
|
||||
configuration: [],
|
||||
dependencies: [],
|
||||
dataClasses: ["personal", "document"],
|
||||
partitionRequired: true,
|
||||
remediation: "/docs/business-services.md",
|
||||
},
|
||||
],
|
||||
// `archive` is the segment the web shell's module mount routes to `archivePage` (ui/pages.tsx).
|
||||
ui: { nav: [{ scope: "project", label: "archive.title", href: "archive", permission: "archive.document.read" }] },
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// What an administrator does to a document's retention: place or lift a legal hold, and dispose of it.
|
||||
// The service decides whether disposal is allowed (a hold blocks it, and the retention must have ended):
|
||||
// a refusal is shown here, and a disposal asks first, because it cannot be undone.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { ConfirmDialog, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
export interface DocumentActionsLabels {
|
||||
readonly heading: string
|
||||
readonly hold: string
|
||||
readonly release: string
|
||||
readonly dispose: string
|
||||
readonly disposeConfirm: string
|
||||
readonly busy: string
|
||||
readonly failed: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function DocumentActions({
|
||||
slugs,
|
||||
documentId,
|
||||
revision,
|
||||
legalHold,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
documentId: string
|
||||
revision: string
|
||||
legalHold: boolean
|
||||
labels: DocumentActionsLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const hold = useWrite<Record<string, unknown>, unknown>("archive.document.hold")
|
||||
const dispose = useWrite<Record<string, unknown>, unknown>("archive.document.dispose")
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const act = async (write: () => Promise<unknown>): Promise<void> => {
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await write()
|
||||
await navigation.refresh()
|
||||
setState({ kind: "idle" })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const request = { ...slugs, documentId, revision, requestId: crypto.randomUUID() }
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={state.kind === "busy"}
|
||||
onClick={() => void act(() => hold.mutate({ ...request, legalHold: !legalHold }))}
|
||||
>
|
||||
{state.kind === "busy" ? labels.busy : legalHold ? labels.release : labels.hold}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
title={labels.dispose}
|
||||
description={labels.disposeConfirm}
|
||||
confirmLabel={labels.dispose}
|
||||
busyLabel={labels.busy}
|
||||
trigger={
|
||||
<Button type="button" variant="destructive" disabled={state.kind === "busy" || legalHold}>
|
||||
{labels.dispose}
|
||||
</Button>
|
||||
}
|
||||
onConfirm={async () => {
|
||||
await dispose.mutate(request)
|
||||
await navigation.refresh()
|
||||
}}
|
||||
/>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useDataClient } from "@conusai-kit/ui/data"
|
||||
import { ResourceList, serviceNotice, useResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useMemo } from "react"
|
||||
import type { DocumentView } from "../contract/index.js"
|
||||
|
||||
export interface DocumentListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly title: string
|
||||
readonly retentionClass: string
|
||||
readonly status: string
|
||||
readonly created: string
|
||||
/** `Open {title}`: the link's accessible name. */
|
||||
readonly open: string
|
||||
readonly search: string
|
||||
readonly searchSubmit: string
|
||||
readonly more: string
|
||||
readonly failed: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
type Page = { rows: DocumentView[]; after?: string | undefined }
|
||||
|
||||
export function DocumentList({
|
||||
slugs,
|
||||
seed,
|
||||
statuses,
|
||||
listHref,
|
||||
labels,
|
||||
locale,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page
|
||||
locale: string
|
||||
statuses: Readonly<Record<DocumentView["status"], string>>
|
||||
listHref: string
|
||||
labels: DocumentListLabels
|
||||
}): ReactElement {
|
||||
const date = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }), [locale])
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<DocumentView>({
|
||||
seed,
|
||||
rowId: (row) => row.documentId,
|
||||
searchLabel: labels.search,
|
||||
describeError: (error) =>
|
||||
labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
|
||||
read: async ({ q, after }) =>
|
||||
(
|
||||
await client.call<Page>("archive.document.list", {
|
||||
...slugs,
|
||||
...(q ? { q } : {}),
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.caption}
|
||||
empty={{ title: labels.empty }}
|
||||
columns={[
|
||||
{ id: "title", header: labels.title, wrap: true },
|
||||
{ id: "retentionClass", header: labels.retentionClass },
|
||||
{ id: "status", header: labels.status, cell: (row) => statuses[row.status] },
|
||||
{ id: "createdAt", header: labels.created, cell: (row) => date.format(new Date(row.createdAt)) },
|
||||
]}
|
||||
rowHref={(row) => `${listHref}/${encodeURIComponent(row.documentId)}`}
|
||||
rowLinkLabel={(row) => labels.open.replace("{title}", row.title)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The archive module's pages and components. `archivePage` is what the web shell mounts; the rest is
|
||||
// exported for an app that composes its own page from the parts.
|
||||
|
||||
export { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
|
||||
export { DocumentList, type DocumentListLabels } from "./document-list.js"
|
||||
export {
|
||||
documentActionsLabels,
|
||||
documentListLabels,
|
||||
registerFormLabels,
|
||||
retentionFormLabels,
|
||||
retentionLabels,
|
||||
type Translate,
|
||||
versionFormLabels,
|
||||
versionsLabels,
|
||||
} from "./labels.js"
|
||||
export { type ArchivePage, archivePage, serviceState } from "./pages.js"
|
||||
export { RegisterForm, type RegisterFormLabels } from "./register-form.js"
|
||||
export { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
|
||||
export { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
|
||||
export { VersionForm, type VersionFormLabels } from "./version-form.js"
|
||||
export { Versions, type VersionsLabels } from "./versions.js"
|
||||
@@ -0,0 +1,125 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use is
|
||||
// named here or in pages.tsx, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks.
|
||||
//
|
||||
// `t` takes a key relative to the module (`documents.title`); the page binds it to
|
||||
// `i18n.message("archive." + key)`. The app's `app.failed` is passed in already looked up, and so is
|
||||
// `refusals`, the kit's sentence for each business refusal by problem code (`businessRefusalNotices`).
|
||||
|
||||
import { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
|
||||
import type { DocumentActionsLabels } from "./document-actions.js"
|
||||
import type { DocumentListLabels } from "./document-list.js"
|
||||
import type { RegisterFormLabels } from "./register-form.js"
|
||||
import type { RetentionLabels } from "./retention-classes.js"
|
||||
import type { RetentionFormLabels } from "./retention-form.js"
|
||||
import type { VersionFormLabels } from "./version-form.js"
|
||||
import type { VersionsLabels } from "./versions.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The kit's sentence for each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
export type Refusals = Readonly<Record<string, string>>
|
||||
|
||||
export const baseNames = (t: Translate): Readonly<Record<RetentionClassView["from"], string>> =>
|
||||
Object.fromEntries(RETENTION_BASES.map((base) => [base, t(`retention.bases.${base}`)])) as Record<
|
||||
RetentionClassView["from"],
|
||||
string
|
||||
>
|
||||
|
||||
export function documentListLabels(t: Translate, failed: string, refusals: Refusals): DocumentListLabels {
|
||||
return {
|
||||
caption: t("documents.caption"),
|
||||
empty: t("documents.empty"),
|
||||
title: t("documents.title"),
|
||||
retentionClass: t("documents.retentionClass"),
|
||||
status: t("documents.status"),
|
||||
created: t("documents.created"),
|
||||
// The placeholder is the component's to fill, so it is passed through untouched.
|
||||
open: t("documents.open", { title: "{title}" }),
|
||||
search: t("documents.search"),
|
||||
searchSubmit: t("documents.searchSubmit"),
|
||||
more: t("documents.more"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function registerFormLabels(t: Translate, failed: string, refusals: Refusals): RegisterFormLabels {
|
||||
return {
|
||||
heading: t("register.heading"),
|
||||
title: t("register.title"),
|
||||
kind: t("register.kind"),
|
||||
kindHint: t("register.kindHint"),
|
||||
retentionClass: t("register.retentionClass"),
|
||||
file: t("register.file"),
|
||||
submit: t("register.submit"),
|
||||
busy: t("register.busy"),
|
||||
noClass: t("register.noClass"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function versionsLabels(t: Translate, count: number): VersionsLabels {
|
||||
return {
|
||||
title: t("versions.title"),
|
||||
count: t("versions.count", { count }),
|
||||
caption: t("versions.caption"),
|
||||
sequence: t("versions.sequence"),
|
||||
mediaType: t("versions.mediaType"),
|
||||
size: t("versions.size"),
|
||||
hash: t("versions.hash"),
|
||||
created: t("versions.created"),
|
||||
}
|
||||
}
|
||||
|
||||
export function versionFormLabels(t: Translate, failed: string, refusals: Refusals): VersionFormLabels {
|
||||
return {
|
||||
heading: t("versionForm.heading"),
|
||||
file: t("versionForm.file"),
|
||||
submit: t("versionForm.submit"),
|
||||
busy: t("versionForm.busy"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function documentActionsLabels(t: Translate, failed: string, refusals: Refusals): DocumentActionsLabels {
|
||||
return {
|
||||
heading: t("actions.heading"),
|
||||
hold: t("actions.hold"),
|
||||
release: t("actions.release"),
|
||||
dispose: t("actions.dispose"),
|
||||
disposeConfirm: t("actions.disposeConfirm"),
|
||||
busy: t("actions.busy"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function retentionLabels(t: Translate): RetentionLabels {
|
||||
return {
|
||||
caption: t("retention.caption"),
|
||||
empty: t("retention.empty"),
|
||||
code: t("retention.code"),
|
||||
keepFor: t("retention.keepFor"),
|
||||
from: t("retention.from"),
|
||||
dueCaption: t("retention.dueCaption"),
|
||||
dueEmpty: t("retention.dueEmpty"),
|
||||
}
|
||||
}
|
||||
|
||||
export function retentionFormLabels(t: Translate, failed: string, refusals: Refusals): RetentionFormLabels {
|
||||
return {
|
||||
heading: t("retentionForm.heading"),
|
||||
code: t("retentionForm.code"),
|
||||
keepFor: t("retentionForm.keepFor"),
|
||||
keepForHint: t("retentionForm.keepForHint"),
|
||||
from: t("retentionForm.from"),
|
||||
submit: t("retentionForm.submit"),
|
||||
busy: t("retentionForm.busy"),
|
||||
bases: baseNames(t),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// The archive module's pages, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
|
||||
// shell runs on the server under `/<org>/<workspace>/<project>/archive`, and the view of what it returned.
|
||||
//
|
||||
// /archive the organization's documents, and the form that registers one
|
||||
// /archive/retention the retention classes, what is due for disposal, and the form that adds a class
|
||||
// /archive/<documentId> one document: its versions, the form that adds one, its legal hold and disposal
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses, and every label is translated in the
|
||||
// loader. A refusal that is the platform's -- the service is not enabled for the organization, the
|
||||
// deployment lacks a scope, the service did not answer -- is the page's own state, not an error page.
|
||||
|
||||
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n, Locale } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import { PageHeader, ServiceStatePage, serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
ARCHIVE_PERMISSIONS,
|
||||
type DocumentView,
|
||||
disposalList,
|
||||
documentGet,
|
||||
documentList,
|
||||
type RetentionClassView,
|
||||
retentionList,
|
||||
type VersionView,
|
||||
} from "../contract/index.js"
|
||||
import { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
|
||||
import { DocumentList, type DocumentListLabels } from "./document-list.js"
|
||||
import {
|
||||
baseNames,
|
||||
documentActionsLabels,
|
||||
documentListLabels,
|
||||
registerFormLabels,
|
||||
retentionFormLabels,
|
||||
retentionLabels,
|
||||
type Translate,
|
||||
versionFormLabels,
|
||||
versionsLabels,
|
||||
} from "./labels.js"
|
||||
import { RegisterForm, type RegisterFormLabels } from "./register-form.js"
|
||||
import { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
|
||||
import { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
|
||||
import { VersionForm, type VersionFormLabels } from "./version-form.js"
|
||||
import { Versions, type VersionsLabels } from "./versions.js"
|
||||
|
||||
/** A platform refusal as the page shows it: the problem's code and its text in the reader's language. */
|
||||
export interface ServiceNotice {
|
||||
readonly code: string
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
type StatusNames = Readonly<Record<DocumentView["status"], string>>
|
||||
|
||||
export interface ArchiveListData {
|
||||
readonly kind: "list"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly retentionLink: string
|
||||
readonly form: RegisterFormLabels
|
||||
readonly list: DocumentListLabels
|
||||
readonly statuses: StatusNames
|
||||
readonly classes: readonly RetentionClassView[]
|
||||
readonly documents: { rows: DocumentView[]; after?: string | undefined }
|
||||
}
|
||||
|
||||
export interface ArchiveDocumentData {
|
||||
readonly kind: "document"
|
||||
readonly title: string
|
||||
readonly back: string
|
||||
readonly document: DocumentView
|
||||
readonly status: string
|
||||
readonly versions: readonly VersionView[]
|
||||
readonly labels: {
|
||||
readonly retentionClass: string
|
||||
readonly status: string
|
||||
readonly revision: string
|
||||
readonly created: string
|
||||
readonly hold: string
|
||||
readonly held: string
|
||||
readonly disposedAt: string
|
||||
readonly versions: VersionsLabels
|
||||
readonly versionForm: VersionFormLabels
|
||||
readonly actions: DocumentActionsLabels
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArchiveRetentionData {
|
||||
readonly kind: "retention"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly back: string
|
||||
readonly list: RetentionLabels
|
||||
readonly form: RetentionFormLabels
|
||||
readonly bases: Readonly<Record<RetentionClassView["from"], string>>
|
||||
readonly classes: readonly RetentionClassView[]
|
||||
readonly due: readonly DocumentView[]
|
||||
}
|
||||
|
||||
/** The service cannot answer: the notice in place of the page. */
|
||||
export interface ArchiveStateData {
|
||||
readonly kind: "state"
|
||||
readonly title: string
|
||||
readonly notice: ServiceNotice
|
||||
}
|
||||
|
||||
export type ArchivePageData = ArchiveListData | ArchiveDocumentData | ArchiveRetentionData | ArchiveStateData
|
||||
|
||||
export type ArchivePage = ProjectPage<TransportCaller, I18n, Container, ArchivePageData>
|
||||
|
||||
/** A document id as the service mints it: one path segment. */
|
||||
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
||||
|
||||
/** The document a path segment names; a segment that does not decode names none. */
|
||||
function documentIdOf(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
throw notFound("no such page")
|
||||
}
|
||||
}
|
||||
|
||||
/** The notice for a platform refusal, or undefined for any other error (which the shell answers). */
|
||||
export function serviceState(error: unknown, locale: Locale): ServiceNotice | undefined {
|
||||
return serviceNotice(error, businessServiceNotices(locale))
|
||||
}
|
||||
|
||||
const statusNames = (t: Translate): StatusNames => ({
|
||||
active: t("statuses.active"),
|
||||
disposed: t("statuses.disposed"),
|
||||
})
|
||||
|
||||
export const archivePage: ArchivePage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<ArchivePageData> {
|
||||
const t: Translate = (key, values) => i18n.message(`archive.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
const refusals = businessRefusalNotices(i18n.locale)
|
||||
const title = t("title")
|
||||
|
||||
const [segment, ...rest] = segments
|
||||
if (rest.length > 0) throw notFound("no such page")
|
||||
const documentId = segment === undefined || segment === "retention" ? undefined : documentIdOf(segment)
|
||||
if (documentId !== undefined && !DOCUMENT_ID.test(documentId)) throw notFound("no such page")
|
||||
|
||||
try {
|
||||
if (segment === "retention") {
|
||||
const [classes, due] = await Promise.all([caller.call(retentionList, slugs), caller.call(disposalList, slugs)])
|
||||
return {
|
||||
kind: "retention",
|
||||
title: t("retention.title"),
|
||||
intro: t("retention.intro"),
|
||||
back: t("nav.documents"),
|
||||
list: retentionLabels(t),
|
||||
form: retentionFormLabels(t, failed, refusals),
|
||||
bases: baseNames(t),
|
||||
classes: classes.rows,
|
||||
due: due.rows,
|
||||
}
|
||||
}
|
||||
if (documentId === undefined) {
|
||||
const [documents, classes] = await Promise.all([
|
||||
caller.call(documentList, slugs),
|
||||
caller.call(retentionList, slugs),
|
||||
])
|
||||
return {
|
||||
kind: "list",
|
||||
title,
|
||||
intro: t("intro"),
|
||||
retentionLink: t("nav.retention"),
|
||||
form: registerFormLabels(t, failed, refusals),
|
||||
list: documentListLabels(t, failed, refusals),
|
||||
statuses: statusNames(t),
|
||||
classes: classes.rows,
|
||||
documents,
|
||||
}
|
||||
}
|
||||
const { document, versions } = await caller.call(documentGet, { ...slugs, documentId })
|
||||
return {
|
||||
kind: "document",
|
||||
title,
|
||||
back: t("document.back"),
|
||||
document,
|
||||
status: statusNames(t)[document.status],
|
||||
versions,
|
||||
labels: {
|
||||
retentionClass: t("document.retentionClass"),
|
||||
status: t("document.status"),
|
||||
revision: t("document.revision"),
|
||||
created: t("document.created"),
|
||||
hold: t("document.hold"),
|
||||
held: document.legalHold ? t("document.yes") : t("document.no"),
|
||||
disposedAt: t("document.disposedAt"),
|
||||
versions: versionsLabels(t, versions.length),
|
||||
versionForm: versionFormLabels(t, failed, refusals),
|
||||
actions: documentActionsLabels(t, failed, refusals),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const notice = serviceState(error, i18n.locale)
|
||||
if (!notice) throw error
|
||||
return { kind: "state", title, notice }
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, data, locale }: ProjectPageViewProps<ArchivePageData>): ReactElement {
|
||||
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/archive`
|
||||
return data.kind === "state" ? (
|
||||
<ServiceStatePage title={data.title} notice={data.notice} />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader title={data.title} />
|
||||
{data.kind === "list" ? (
|
||||
<>
|
||||
<p>{data.intro}</p>
|
||||
<p>
|
||||
<a href={`${listHref}/retention`}>{data.retentionLink}</a>
|
||||
</p>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.write}>
|
||||
<RegisterForm slugs={slugs} classes={data.classes} listHref={listHref} labels={data.form} />
|
||||
</Can>
|
||||
<DocumentList
|
||||
locale={locale}
|
||||
slugs={slugs}
|
||||
seed={data.documents}
|
||||
statuses={data.statuses}
|
||||
listHref={listHref}
|
||||
labels={data.list}
|
||||
/>
|
||||
</>
|
||||
) : data.kind === "retention" ? (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{data.back}</a>
|
||||
</p>
|
||||
<p>{data.intro}</p>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.manage}>
|
||||
<RetentionForm slugs={slugs} labels={data.form} />
|
||||
</Can>
|
||||
<RetentionClasses
|
||||
classes={data.classes}
|
||||
due={data.due}
|
||||
bases={data.bases}
|
||||
listHref={listHref}
|
||||
labels={data.list}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{data.back}</a>
|
||||
</p>
|
||||
<h2>{data.document.title}</h2>
|
||||
<dl>
|
||||
<dt>{data.labels.status}</dt>
|
||||
<dd>{data.status}</dd>
|
||||
<dt>{data.labels.retentionClass}</dt>
|
||||
<dd>{data.document.retentionClass}</dd>
|
||||
<dt>{data.labels.created}</dt>
|
||||
<dd>{data.document.createdAt.slice(0, 10)}</dd>
|
||||
<dt>{data.labels.revision}</dt>
|
||||
<dd>{data.document.revision}</dd>
|
||||
<dt>{data.labels.hold}</dt>
|
||||
<dd>{data.labels.held}</dd>
|
||||
{data.document.disposedAt === null ? null : (
|
||||
<>
|
||||
<dt>{data.labels.disposedAt}</dt>
|
||||
<dd>{data.document.disposedAt.slice(0, 10)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<Versions versions={data.versions} labels={data.labels.versions} />
|
||||
{data.document.disposed ? null : (
|
||||
<>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.write}>
|
||||
<VersionForm
|
||||
slugs={slugs}
|
||||
documentId={data.document.documentId}
|
||||
revision={data.document.revision}
|
||||
labels={data.labels.versionForm}
|
||||
/>
|
||||
</Can>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.manage}>
|
||||
<DocumentActions
|
||||
slugs={slugs}
|
||||
documentId={data.document.documentId}
|
||||
revision={data.document.revision}
|
||||
legalHold={data.document.legalHold}
|
||||
labels={data.labels.actions}
|
||||
/>
|
||||
</Can>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Registers a document: the browser stores the file through the files module, then asks this module to
|
||||
// register it, which takes the version's hash, size and type from the stored file. Then it opens the
|
||||
// document.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import { SelectField, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives"
|
||||
import { type FormEvent, type ReactElement, useState } from "react"
|
||||
import type { RetentionClassView } from "../contract/index.js"
|
||||
|
||||
export interface RegisterFormLabels {
|
||||
readonly heading: string
|
||||
readonly title: string
|
||||
readonly kind: string
|
||||
readonly kindHint: string
|
||||
readonly retentionClass: string
|
||||
readonly file: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly noClass: string
|
||||
readonly failed: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function RegisterForm({
|
||||
slugs,
|
||||
classes,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
classes: readonly RetentionClassView[]
|
||||
listHref: string
|
||||
labels: RegisterFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const upload = useUpload()
|
||||
const register = useWrite<Record<string, unknown>, { documentId: string }>("archive.document.register")
|
||||
const [requestId] = useState(() => crypto.randomUUID())
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const file = form.get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
const kind = String(form.get("kind") ?? "").trim()
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const stored = await upload(slugs, file)
|
||||
const created = await register.mutate({
|
||||
...slugs,
|
||||
title: String(form.get("title") ?? "").trim(),
|
||||
...(kind ? { kind } : {}),
|
||||
retentionClass: String(form.get("retentionClass") ?? ""),
|
||||
fileId: stored.fileId,
|
||||
requestId,
|
||||
})
|
||||
navigation.navigate(`${listHref}/${encodeURIComponent(created.documentId)}`)
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return classes.length === 0 ? (
|
||||
<p>{labels.noClass}</p>
|
||||
) : (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.title}</FieldLabel>
|
||||
<Input name="title" type="text" required maxLength={200} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{labels.kind}</FieldLabel>
|
||||
<Input name="kind" type="text" pattern="[0-9]{1,4}" />
|
||||
<FieldDescription>{labels.kindHint}</FieldDescription>
|
||||
</Field>
|
||||
<SelectField
|
||||
name="retentionClass"
|
||||
label={labels.retentionClass}
|
||||
required
|
||||
defaultValue={classes[0]?.code}
|
||||
options={classes.map((retention) => ({ value: retention.code, label: retention.code }))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel>{labels.file}</FieldLabel>
|
||||
<Input name="file" type="file" required />
|
||||
</Field>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
// The retention classes, and the documents due for disposal. A server component: it holds no state, and
|
||||
// every string is passed in.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import type { DocumentView, RetentionClassView } from "../contract/index.js"
|
||||
|
||||
export interface RetentionLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly code: string
|
||||
readonly keepFor: string
|
||||
readonly from: string
|
||||
readonly dueCaption: string
|
||||
readonly dueEmpty: string
|
||||
}
|
||||
|
||||
export function RetentionClasses({
|
||||
classes,
|
||||
due,
|
||||
bases,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
classes: readonly RetentionClassView[]
|
||||
due: readonly DocumentView[]
|
||||
bases: Readonly<Record<RetentionClassView["from"], string>>
|
||||
listHref: string
|
||||
labels: RetentionLabels
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{classes.length === 0 ? (
|
||||
<p>{labels.empty}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.code}</TableHead>
|
||||
<TableHead scope="col">{labels.keepFor}</TableHead>
|
||||
<TableHead scope="col">{labels.from}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{classes.map((retention) => (
|
||||
<TableRow key={retention.code}>
|
||||
<TableHead scope="row">{retention.code}</TableHead>
|
||||
<TableCell>{retention.keepFor}</TableCell>
|
||||
<TableCell>{bases[retention.from]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{due.length === 0 ? (
|
||||
<p>{labels.dueEmpty}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableCaption>{labels.dueCaption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.code}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{due.map((document) => (
|
||||
<TableRow key={document.documentId}>
|
||||
<TableHead scope="row">
|
||||
<a href={`${listHref}/${encodeURIComponent(document.documentId)}`}>{document.title}</a>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Adds a retention class. A class never changes once it exists: a new policy is a new class.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { SelectField, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives"
|
||||
import { type FormEvent, type ReactElement, useState } from "react"
|
||||
import { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
|
||||
|
||||
export interface RetentionFormLabels {
|
||||
readonly heading: string
|
||||
readonly code: string
|
||||
readonly keepFor: string
|
||||
readonly keepForHint: string
|
||||
readonly from: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly bases: Readonly<Record<RetentionClassView["from"], string>>
|
||||
readonly failed: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function RetentionForm({ slugs, labels }: { slugs: ProjectSlugs; labels: RetentionFormLabels }): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const create = useWrite<Record<string, unknown>, unknown>("archive.retention.create")
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await create.mutate({
|
||||
...slugs,
|
||||
code: String(form.get("code") ?? "").trim(),
|
||||
keepFor: String(form.get("keepFor") ?? "").trim(),
|
||||
from: String(form.get("from") ?? "created"),
|
||||
requestId,
|
||||
})
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
setState({ kind: "idle" })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.code}</FieldLabel>
|
||||
<Input name="code" type="text" required maxLength={64} pattern="[A-Za-z0-9_\-]+" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{labels.keepFor}</FieldLabel>
|
||||
<Input name="keepFor" type="text" required maxLength={64} />
|
||||
<FieldDescription>{labels.keepForHint}</FieldDescription>
|
||||
</Field>
|
||||
<SelectField
|
||||
name="from"
|
||||
label={labels.from}
|
||||
defaultValue="created"
|
||||
options={RETENTION_BASES.map((base) => ({ value: base, label: labels.bases[base] }))}
|
||||
/>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Adds a version: the browser stores the file through the files module, then asks this module to append
|
||||
// it over the revision the page read. A version nobody else has overtaken lands; a stale one is refused.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import { serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription, Field, FieldLabel, Input } from "@conusai-kit/ui/primitives"
|
||||
import { type FormEvent, type ReactElement, useState } from "react"
|
||||
|
||||
export interface VersionFormLabels {
|
||||
readonly heading: string
|
||||
readonly file: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly failed: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function VersionForm({
|
||||
slugs,
|
||||
documentId,
|
||||
revision,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
documentId: string
|
||||
revision: string
|
||||
labels: VersionFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const upload = useUpload()
|
||||
const add = useWrite<Record<string, unknown>, unknown>("archive.version.add")
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const file = new FormData(event.currentTarget).get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const stored = await upload(slugs, file)
|
||||
await add.mutate({ ...slugs, documentId, revision, fileId: stored.fileId, requestId })
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
setState({ kind: "idle" })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.file}</FieldLabel>
|
||||
<Input name="file" type="file" required />
|
||||
</Field>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
// A document's versions. A server component: it holds no state, and every string is passed in.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import type { VersionView } from "../contract/index.js"
|
||||
|
||||
export interface VersionsLabels {
|
||||
readonly title: string
|
||||
/** How many versions, already filled with the plural form. */
|
||||
readonly count: string
|
||||
readonly caption: string
|
||||
readonly sequence: string
|
||||
readonly mediaType: string
|
||||
readonly size: string
|
||||
readonly hash: string
|
||||
readonly created: string
|
||||
}
|
||||
|
||||
export function Versions({
|
||||
versions,
|
||||
labels,
|
||||
}: {
|
||||
versions: readonly VersionView[]
|
||||
labels: VersionsLabels
|
||||
}): ReactElement {
|
||||
return (
|
||||
<section>
|
||||
<h2>{labels.title}</h2>
|
||||
<p>{labels.count}</p>
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.sequence}</TableHead>
|
||||
<TableHead scope="col">{labels.mediaType}</TableHead>
|
||||
<TableHead scope="col">{labels.size}</TableHead>
|
||||
<TableHead scope="col">{labels.hash}</TableHead>
|
||||
<TableHead scope="col">{labels.created}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{versions.map((version) => (
|
||||
<TableRow key={version.versionId}>
|
||||
<TableHead scope="row">{version.sequence}</TableHead>
|
||||
<TableCell>{version.mediaType}</TableCell>
|
||||
<TableCell>{version.size}</TableCell>
|
||||
<TableCell>
|
||||
<code>{version.sha256.slice(0, 12)}</code>
|
||||
</TableCell>
|
||||
<TableCell>{version.createdAt.slice(0, 10)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user