@@ -0,0 +1,30 @@
|
||||
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
|
||||
# Activities
|
||||
|
||||
Status: Implementation in progress; disabled by default; live acceptance pending.
|
||||
|
||||
`kit module:on activities` includes task creation/editing, complete/reopen, discussion and a
|
||||
project task timeline. It requests only Activities read/write and comment-write scopes. An
|
||||
owner/admin must separately enable the service and approve permissions; the current verified
|
||||
binding, compatible service release and dependencies must be ready. Convex is explicitly
|
||||
unsupported. Turning the module off retains service records.
|
||||
|
||||
The adapter resolves the current server-prepared `RequestBusinessBinding` after app membership
|
||||
and permission checks. There is no deployment singleton or organization-wide fallback. The
|
||||
service enforces the complete app/environment partition; this module additionally derives the
|
||||
app project reference from resolved scope and refuses foreign-project task IDs before reads or
|
||||
mutations. Browser input cannot select a binding, actor, participant or arbitrary subject.
|
||||
|
||||
Tasks and comments live only in the Activities service. Member participant references are opaque
|
||||
and attributable; scoped export/erasure and retained audit obligations remain mandatory. There
|
||||
is no duplicate app activity table, browser storage, default delete/administrative privacy grant,
|
||||
email transmission or AI processing. Pending scoped privacy/job/callback acceptance is a blocker,
|
||||
not proof that a legacy organization-only callback erased app data.
|
||||
|
||||
Mutations use stable per-submission request UUIDs, project/operation namespaces and the revision
|
||||
shown to the reviewer. Task updates preserve fields the form does not edit. Service policy
|
||||
refusals are bounded app states; remote error bodies are never rendered or logged by this module.
|
||||
|
||||
Run the packed-template gate and module isolation suite. Fake ports prove UI/contracts and
|
||||
project ownership handling only. J10/J14/J16/J18 require the real signed gateway, database runtime
|
||||
roles, persisted results, callbacks, privacy obligations and browser/API/MCP verification.
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Simulated ports prove module boundaries, not live service/database acceptance.
|
||||
import { actorRef, projectRef } from "@conusai-kit/business"
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { ActivitiesService, TaskRecord } from "../core/ports.js"
|
||||
import { addComment, changeTask, createTask, listComments, listTasks, ownedTask, updateTask } from "../core/tasks.js"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { lt } from "../i18n/lt.js"
|
||||
|
||||
const bindingId = "01900000-0000-7000-8000-000000000001"
|
||||
const projectId = "01900000-0000-7000-8000-000000000002"
|
||||
const taskId = "act_01900000-0000-7000-8000-000000000003"
|
||||
const userId = "01900000-0000-7000-8000-000000000004"
|
||||
const requestId = "01900000-0000-7000-8000-000000000005"
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
orgId: bindingId,
|
||||
workspaceId: bindingId,
|
||||
projectId,
|
||||
actor: { userId: userId as never },
|
||||
}
|
||||
function fixture() {
|
||||
const task: TaskRecord = {
|
||||
taskId,
|
||||
subject: "Synthetic task",
|
||||
body: "Fixture details",
|
||||
revision: "1",
|
||||
status: "open",
|
||||
targets: [projectRef(bindingId, projectId)],
|
||||
createdAt: "2026-11-03T00:00:00Z",
|
||||
}
|
||||
const outcomes = new Map<string, { taskId: string; revision: string }>()
|
||||
let commits = 0
|
||||
const service: ActivitiesService = {
|
||||
bindingId,
|
||||
get: vi.fn(async () => ({ ...task })),
|
||||
list: vi.fn(async () => ({ rows: [{ ...task }] })),
|
||||
comments: vi.fn(async () => ({ rows: [] })),
|
||||
timeline: vi.fn(async () => ({ rows: [{ ...task }] })),
|
||||
create: vi.fn(async () => ({ taskId, revision: "1" })),
|
||||
update: vi.fn(async () => ({ taskId, revision: "2" })),
|
||||
comment: vi.fn(async () => ({
|
||||
commentId: "cmt_01900000-0000-7000-8000-000000000003",
|
||||
revision: "2",
|
||||
})),
|
||||
change: vi.fn(async (input) => {
|
||||
const prior = outcomes.get(input.key)
|
||||
if (prior) return prior
|
||||
if (input.expectedVersion !== task.revision) throw new Error("fixture stale revision")
|
||||
task.status = input.state === "done" ? "done" : "open"
|
||||
task.revision = (BigInt(task.revision) + 1n).toString()
|
||||
const result = { taskId, revision: task.revision }
|
||||
outcomes.set(input.key, result)
|
||||
commits += 1
|
||||
return result
|
||||
}),
|
||||
}
|
||||
return { task, service, commits: () => commits }
|
||||
}
|
||||
describe("project Activities module boundaries (simulated ports)", () => {
|
||||
it("derives project and participant references from resolved scope, with an operation key", async () => {
|
||||
const { service } = fixture()
|
||||
await createTask(service, scope, { subject: " Task ", requestId })
|
||||
expect(service.create).toHaveBeenCalledWith({
|
||||
subject: "Task",
|
||||
body: "",
|
||||
target: projectRef(bindingId, projectId),
|
||||
participant: actorRef(bindingId, userId),
|
||||
key: `activities:${projectId}:create:${requestId}`,
|
||||
})
|
||||
await listTasks(service, scope, {})
|
||||
expect(service.list).toHaveBeenCalledWith({ target: projectRef(bindingId, projectId), status: "open" })
|
||||
})
|
||||
it("refuses guessed task ids from another app project before writes or discussion reads", async () => {
|
||||
const { task, service } = fixture()
|
||||
task.targets = [projectRef(bindingId, "01900000-0000-7000-8000-000000000099")]
|
||||
for (const operation of [
|
||||
() => ownedTask(service, scope, taskId),
|
||||
() => listComments(service, scope, { taskId }),
|
||||
() => addComment(service, scope, { taskId, body: "Synthetic", expectedVersion: "1", requestId }),
|
||||
() => changeTask(service, scope, { taskId, expectedVersion: "1", requestId }, "done"),
|
||||
])
|
||||
await expect(operation()).rejects.toMatchObject({ code: "not_found" })
|
||||
expect(service.comment).not.toHaveBeenCalled()
|
||||
expect(service.change).not.toHaveBeenCalled()
|
||||
expect(service.comments).not.toHaveBeenCalled()
|
||||
})
|
||||
it("refuses future revision prediction before external mutation", async () => {
|
||||
const { service } = fixture()
|
||||
await expect(changeTask(service, scope, { taskId, expectedVersion: "2", requestId }, "done")).rejects.toMatchObject(
|
||||
{ code: "conflict" },
|
||||
)
|
||||
expect(service.change).not.toHaveBeenCalled()
|
||||
})
|
||||
it("lets the service deduplicate a lost-response retry without a second domain commit", async () => {
|
||||
const f = fixture()
|
||||
const input = { taskId, expectedVersion: "1", requestId }
|
||||
const first = await changeTask(f.service, scope, input, "done")
|
||||
expect(await changeTask(f.service, scope, input, "done")).toEqual(first)
|
||||
expect(f.commits()).toBe(1)
|
||||
expect(await changeTask(f.service, scope, { ...input, expectedVersion: "2" }, "open")).toEqual({
|
||||
taskId,
|
||||
revision: "3",
|
||||
})
|
||||
expect(f.commits()).toBe(2)
|
||||
})
|
||||
it("rechecks project ownership after discussion reads", async () => {
|
||||
const { service } = fixture()
|
||||
vi.mocked(service.comments).mockImplementation(async () => {
|
||||
vi.mocked(service.get).mockResolvedValue({
|
||||
...fixture().task,
|
||||
targets: [projectRef(bindingId, "01900000-0000-7000-8000-000000000099")],
|
||||
})
|
||||
return { rows: [{ commentId: taskId, body: "Must not be returned", author: null, erased: false }] }
|
||||
})
|
||||
await expect(listComments(service, scope, { taskId })).rejects.toMatchObject({ code: "not_found" })
|
||||
})
|
||||
it("passes the authorized target to an update so the adapter preserves other fields", async () => {
|
||||
const { service } = fixture()
|
||||
await updateTask(service, scope, { taskId, subject: "Changed", body: "Details", expectedVersion: "1", requestId })
|
||||
expect(service.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: projectRef(bindingId, projectId), expectedVersion: "1", subject: "Changed" }),
|
||||
)
|
||||
})
|
||||
it("has the same translated keys in both languages", () =>
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(lt).sort()))
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { businessAppError, toBusinessError } from "@conusai-kit/business"
|
||||
import { businessRefusalNotices } from "@conusai-kit/business/refusals"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { lt } from "../i18n/lt.js"
|
||||
import { describeFailure, type Labels } from "../ui/labels.js"
|
||||
|
||||
// What the loader hands the forms (ui/pages.tsx): the module's messages and the kit's refusal sentences.
|
||||
const labelsOf = (messages: typeof en, locale: "en" | "lt"): Labels => ({
|
||||
...messages,
|
||||
refusals: businessRefusalNotices(locale, {
|
||||
conflict: messages.conflict,
|
||||
revision_conflict: messages.conflict,
|
||||
invalid: messages.invalid,
|
||||
}),
|
||||
})
|
||||
|
||||
describe("Activities error text", () => {
|
||||
it.each([
|
||||
[en, "en"],
|
||||
[lt, "lt"],
|
||||
] as const)("translates known refusals without showing remote detail", (messages, locale) => {
|
||||
const labels = labelsOf(messages, locale)
|
||||
for (const [code, expected] of [
|
||||
["business_conflict", messages.conflict],
|
||||
["business_revision_conflict", messages.conflict],
|
||||
["business_invalid", messages.invalid],
|
||||
["service_partition_revoked", labels.refusals.service_partition_revoked],
|
||||
["service_degraded", labels.refusals.service_degraded],
|
||||
["service_migrating", labels.refusals.service_migrating],
|
||||
]) {
|
||||
const message = describeFailure({ problem: { code, detail: "upstream private fixture value" } }, labels)
|
||||
expect(message).toBe(expected)
|
||||
expect(message).not.toContain("private fixture value")
|
||||
}
|
||||
expect(describeFailure({ problem: { code: "untrusted", detail: "upstream private fixture value" } }, labels)).toBe(
|
||||
messages.unavailable,
|
||||
)
|
||||
})
|
||||
|
||||
it("tells a Lithuanian reader the kit's sentence for a refusal of the Activities service", () => {
|
||||
const refusal = businessAppError(
|
||||
toBusinessError(
|
||||
{ type: "urn:conusai:activities:erased-participant", detail: "upstream private fixture value" },
|
||||
new Response(null, { status: 409 }),
|
||||
),
|
||||
)
|
||||
expect(describeFailure(refusal, labelsOf(lt, "lt"))).toBe(
|
||||
"Dalyvio duomenys ištrinti, todėl šios veiklos pakeisti negalima.",
|
||||
)
|
||||
expect(describeFailure({ problem: { code: "service_migrating" } }, labelsOf(lt, "lt"))).toBe(
|
||||
"Paslauga atnaujinama. Bandykite po kelių minučių.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const id = "01900000-0000-7000-8000-000000000001"
|
||||
const slugs = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
const task = (t: IsolationTenant) => ({ ...slugs(t), taskId: `act_${id}` })
|
||||
const mutate = (t: IsolationTenant) => ({ ...task(t), expectedVersion: "1", requestId: id })
|
||||
export const activitiesIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"activities.task.list": { input: slugs },
|
||||
"activities.task.get": { input: task },
|
||||
"activities.timeline.list": { input: slugs },
|
||||
"activities.task.create": { input: (t) => ({ ...slugs(t), subject: "Synthetic", requestId: id }) },
|
||||
"activities.task.update": { input: (t) => ({ ...mutate(t), subject: "Synthetic", body: "Fixture" }) },
|
||||
"activities.task.complete": { input: mutate },
|
||||
"activities.task.reopen": { input: mutate },
|
||||
"activities.comment.list": { input: task },
|
||||
"activities.comment.add": { input: (t) => ({ ...mutate(t), body: "Synthetic" }) },
|
||||
}
|
||||
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("activities") ? activitiesIsolationFixtures : {} }
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated SDK + synthetic HTTP proves wire contracts, never live service acceptance.
|
||||
import { actorRef, projectRef, requestBusinessBinding } from "@conusai-kit/business"
|
||||
import { businessServiceHooks } from "@conusai-kit/business/server"
|
||||
import { Container, type ServiceAdmissionContext } from "@conusai-kit/core"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { sdkActivitiesService } from "../adapters/service.js"
|
||||
import { activityId, commentReceipt, receipt } from "../contract/index.js"
|
||||
import { activitiesModule } from "../module.js"
|
||||
|
||||
const bindingId = "01900000-0000-7000-8000-000000000001"
|
||||
const projectId = "01900000-0000-7000-8000-000000000002"
|
||||
const actorId = "01900000-0000-7000-8000-000000000003"
|
||||
const taskId = "act_01900000-0000-7000-8000-000000000004"
|
||||
const commentId = "cmt_01900000-0000-7000-8000-000000000005"
|
||||
const revision = "9007199254740993"
|
||||
const target = projectRef(bindingId, projectId)
|
||||
const member = actorRef(bindingId, actorId)
|
||||
const task = {
|
||||
id: taskId,
|
||||
kind: "task",
|
||||
status: "open",
|
||||
revision: "RAW_REVISION",
|
||||
subject: "Synthetic task",
|
||||
body: "Details",
|
||||
targets: [target],
|
||||
participants: [member],
|
||||
assignee: member,
|
||||
due_at: "2026-12-01T00:00:00Z",
|
||||
ext: { fixture: "preserve" },
|
||||
pinned: true,
|
||||
created_at: "2026-11-03T00:00:00Z",
|
||||
updated_at: "2026-11-03T00:00:00Z",
|
||||
erased: false,
|
||||
deleted: false,
|
||||
}
|
||||
function json(value: unknown) {
|
||||
return new Response(JSON.stringify(value).replaceAll('"RAW_REVISION"', revision), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
async function fixture(reply: (request: Request) => Promise<Response> | Response) {
|
||||
const requests: Request[] = []
|
||||
vi.stubGlobal("fetch", async (input: Request | string | URL) => {
|
||||
const request = new Request(input)
|
||||
requests.push(request.clone())
|
||||
return reply(request)
|
||||
})
|
||||
const container = new Container()
|
||||
const hooks = businessServiceHooks({
|
||||
env: { apiUrl: "https://platform.example.test/api", token: "synthetic-token", orgSlug: "fixture" },
|
||||
platformOrganizationId: 7,
|
||||
platformProjectId: 9,
|
||||
platformEnvironmentId: 11,
|
||||
profile: "postgres",
|
||||
controlPlane: {
|
||||
lookup: async (appTenantId) => ({
|
||||
partition_major: 1,
|
||||
binding_id: bindingId,
|
||||
organization_id: 7,
|
||||
project_id: 9,
|
||||
environment_id: 11,
|
||||
environment_uuid: projectId,
|
||||
app_tenant_id: appTenantId,
|
||||
}),
|
||||
observe: async () => {
|
||||
throw new Error("This fixture does not certify readiness")
|
||||
},
|
||||
},
|
||||
})
|
||||
const context: ServiceAdmissionContext = {
|
||||
container,
|
||||
module: "activities",
|
||||
procedure: "activities.task.list",
|
||||
kind: "read",
|
||||
scope: { level: "org", orgId: bindingId, actor: { userId: actorId } },
|
||||
}
|
||||
await hooks.prepareServices?.(container, context, activitiesModule.serviceRequirements ?? [])
|
||||
return { service: sdkActivitiesService(requestBusinessBinding(container)), requests }
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe("Activities generated wire adapter (synthetic HTTP)", () => {
|
||||
it("accepts real resource prefixes and preserves int64 revision digits", async () => {
|
||||
const { service, requests } = await fixture(() => json(task))
|
||||
const result = await service.get(taskId)
|
||||
expect(result.revision).toBe(revision)
|
||||
expect(activityId.parse(result.taskId)).toBe(taskId)
|
||||
expect(() => activityId.parse(taskId.slice(4))).toThrow()
|
||||
expect(requests[0]?.headers.get("Conusai-App-Binding")).toBe(bindingId)
|
||||
expect(requests[0]?.headers.get("Conusai-App-Actor")).toBe(actorId)
|
||||
expect(requests[0]?.redirect).toBe("error")
|
||||
})
|
||||
it("forwards a service cursor unchanged in both directions", async () => {
|
||||
const cursor = "eyJvcmRlciI6WzEsInN5bnRoZXRpYyJdfQ"
|
||||
const { service, requests } = await fixture(() => json({ data: [task], next_after: cursor }))
|
||||
expect((await service.list({ target, after: cursor })).after).toBe(cursor)
|
||||
expect(new URL(requests[0]?.url ?? "").searchParams.get("after")).toBe(cursor)
|
||||
expect(new URL(requests[0]?.url ?? "").searchParams.get("target")).toBe(target)
|
||||
})
|
||||
it("preserves untouched fields on edit and sends revision digits exactly", async () => {
|
||||
let body = ""
|
||||
const { service } = await fixture(async (request) => {
|
||||
if (request.method === "GET") return json(task)
|
||||
body = await request.text()
|
||||
return json({ id: taskId, revision: "RAW_REVISION" })
|
||||
})
|
||||
const current = await service.get(taskId)
|
||||
const result = await service.update({
|
||||
task: current,
|
||||
target,
|
||||
subject: "Changed",
|
||||
body: "Edited",
|
||||
expectedVersion: revision,
|
||||
key: "synthetic-update",
|
||||
})
|
||||
expect(receipt.parse(result).revision).toBe(revision)
|
||||
expect(body).toContain(`"revision":${revision}`)
|
||||
expect(JSON.parse(body).fields).toMatchObject({
|
||||
assignee: member,
|
||||
due_at: task.due_at,
|
||||
ext: task.ext,
|
||||
participants: [member],
|
||||
targets: [target],
|
||||
pinned: true,
|
||||
subject: "Changed",
|
||||
body: "Edited",
|
||||
})
|
||||
})
|
||||
it("returns the actual comment receipt instead of inventing a comment body", async () => {
|
||||
const { service } = await fixture(() => json({ id: commentId, revision: "RAW_REVISION" }))
|
||||
const result = await service.comment({
|
||||
taskId,
|
||||
body: "Synthetic comment",
|
||||
expectedVersion: revision,
|
||||
key: "synthetic-comment",
|
||||
})
|
||||
expect(commentReceipt.parse(result)).toEqual({ commentId, revision })
|
||||
expect(result).not.toHaveProperty("body")
|
||||
})
|
||||
it("refuses a changed project target before the adapter writes", async () => {
|
||||
const other = projectRef(bindingId, "01900000-0000-7000-8000-000000000099")
|
||||
const { service, requests } = await fixture(() => json({ ...task, targets: [other] }))
|
||||
await expect(
|
||||
service.update({
|
||||
task: {
|
||||
taskId,
|
||||
subject: "Old",
|
||||
body: "",
|
||||
revision,
|
||||
status: "open",
|
||||
targets: [target],
|
||||
createdAt: task.created_at,
|
||||
},
|
||||
target,
|
||||
subject: "Changed",
|
||||
body: "Edited",
|
||||
expectedVersion: revision,
|
||||
key: "synthetic-foreign",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "not_found" })
|
||||
expect(requests.every((request) => request.method === "GET")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { implement } from "@conusai-kit/core"
|
||||
import * as contract from "../contract/index.js"
|
||||
import * as tasks from "../core/tasks.js"
|
||||
import { activitiesServiceOf } from "./service.js"
|
||||
export const ACTIVITIES_PROCEDURES = [
|
||||
implement(contract.taskList, ({ ctx, input }) =>
|
||||
tasks.listTasks(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input),
|
||||
),
|
||||
implement(contract.taskGet, ({ ctx, input }) =>
|
||||
tasks.ownedTask(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input.taskId),
|
||||
),
|
||||
implement(contract.taskCreate, ({ ctx, input }) =>
|
||||
tasks.createTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input),
|
||||
),
|
||||
implement(contract.taskUpdate, ({ ctx, input }) =>
|
||||
tasks.updateTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input),
|
||||
),
|
||||
implement(contract.taskComplete, ({ ctx, input }) =>
|
||||
tasks.changeTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input, "done"),
|
||||
),
|
||||
implement(contract.taskReopen, ({ ctx, input }) =>
|
||||
tasks.changeTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input, "open"),
|
||||
),
|
||||
implement(contract.commentList, ({ ctx, input }) =>
|
||||
tasks.listComments(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input),
|
||||
),
|
||||
implement(contract.commentAdd, ({ ctx, input }) =>
|
||||
tasks.addComment(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input),
|
||||
),
|
||||
implement(contract.timeline, ({ ctx, input }) =>
|
||||
tasks.projectTimeline(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input.after),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,201 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The generated client receives only the current server-prepared binding.
|
||||
import {
|
||||
businessAppError,
|
||||
type RequestBusinessBinding,
|
||||
requestBusinessBinding,
|
||||
toBusinessError,
|
||||
} from "@conusai-kit/business"
|
||||
import { type Container, notFound, type ProcedureKind } from "@conusai-kit/core"
|
||||
import {
|
||||
type Activity,
|
||||
activitiesAddComment,
|
||||
activitiesCompleteTask,
|
||||
activitiesCreateTask,
|
||||
activitiesFindActivities,
|
||||
activitiesGetActivity,
|
||||
activitiesListComments,
|
||||
activitiesReopenTask,
|
||||
activitiesTimelineFor,
|
||||
activitiesUpdateActivity,
|
||||
type Comment,
|
||||
type Fields,
|
||||
type Receipt,
|
||||
} from "@conusai-sdk/activities"
|
||||
import { createClient } from "@conusai-sdk/activities/client"
|
||||
import type { CommentView, TaskReceipt } from "../contract/index.js"
|
||||
import {
|
||||
ACTIVITIES_SERVICE,
|
||||
type ActivitiesReads,
|
||||
type ActivitiesService,
|
||||
readsOf,
|
||||
type TaskRecord,
|
||||
} from "../core/ports.js"
|
||||
|
||||
async function answer<T>(
|
||||
operation: Promise<{ data?: T; error?: unknown; response?: Response }>,
|
||||
): Promise<Exclude<T, undefined>> {
|
||||
let result: Awaited<typeof operation>
|
||||
try {
|
||||
result = await operation
|
||||
} 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 view = (value: Activity): TaskRecord => {
|
||||
if (value.kind !== "task" || value.status === undefined || value.status === null || value.erased || value.deleted)
|
||||
throw notFound()
|
||||
return {
|
||||
taskId: value.id,
|
||||
subject: value.subject,
|
||||
body: value.body ?? "",
|
||||
revision: value.revision.toString(),
|
||||
status: value.status,
|
||||
targets: value.targets,
|
||||
createdAt: value.created_at,
|
||||
}
|
||||
}
|
||||
const commentView = (value: Comment): CommentView => ({
|
||||
commentId: value.id,
|
||||
body: value.erased ? "" : value.body,
|
||||
author: value.author ?? null,
|
||||
erased: value.erased,
|
||||
})
|
||||
const receiptView = (value: Receipt): TaskReceipt => ({ taskId: value.id, revision: value.revision.toString() })
|
||||
const fieldsOf = (row: Activity): Fields => ({
|
||||
subject: row.subject,
|
||||
targets: row.targets,
|
||||
...(row.body === undefined ? {} : { body: row.body }),
|
||||
...(row.assignee === undefined ? {} : { assignee: row.assignee }),
|
||||
...(row.due_at === undefined ? {} : { due_at: row.due_at }),
|
||||
...(row.ext === undefined ? {} : { ext: row.ext }),
|
||||
...(row.participants === undefined ? {} : { participants: row.participants }),
|
||||
...(row.pinned === undefined ? {} : { pinned: row.pinned }),
|
||||
})
|
||||
|
||||
/** No org-wide client constructor or deployment singleton is supported. */
|
||||
export function sdkActivitiesService(binding: RequestBusinessBinding): ActivitiesService {
|
||||
const client = binding.client(createClient)
|
||||
const org = { org_slug: binding.orgSlug }
|
||||
const get = (id: string) => answer(activitiesGetActivity({ client, path: { ...org, activity_id: id } }))
|
||||
return {
|
||||
bindingId: binding.bindingId,
|
||||
async list(input) {
|
||||
const result = await answer(
|
||||
activitiesFindActivities({
|
||||
client,
|
||||
path: org,
|
||||
query: {
|
||||
target: input.target,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.after ? { after: input.after } : {}),
|
||||
limit: 50n,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rows: result.data.filter((row) => row.kind === "task" && !row.erased && !row.deleted).map(view),
|
||||
...(result.next_after ? { after: result.next_after } : {}),
|
||||
}
|
||||
},
|
||||
async get(id) {
|
||||
return view(await get(id))
|
||||
},
|
||||
async timeline(input) {
|
||||
const result = await answer(
|
||||
activitiesTimelineFor({
|
||||
client,
|
||||
path: org,
|
||||
query: { target: input.target, ...(input.after ? { after: input.after } : {}), limit: 50n },
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rows: result.data.filter((row) => row.kind === "task" && !row.erased && !row.deleted).map(view),
|
||||
...(result.next_after ? { after: result.next_after } : {}),
|
||||
}
|
||||
},
|
||||
async comments(input) {
|
||||
const result = await answer(
|
||||
activitiesListComments({
|
||||
client,
|
||||
path: { ...org, activity_id: input.taskId },
|
||||
query: {
|
||||
...(input.after ? { after: input.after } : {}),
|
||||
limit: 50n,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return { rows: result.data.map(commentView), ...(result.next_after ? { after: result.next_after } : {}) }
|
||||
},
|
||||
async create(input) {
|
||||
return receiptView(
|
||||
await answer(
|
||||
activitiesCreateTask({
|
||||
client,
|
||||
path: org,
|
||||
body: {
|
||||
kind: "task",
|
||||
idempotency_key: input.key,
|
||||
fields: {
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
targets: [input.target],
|
||||
participants: [input.participant],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async update(input) {
|
||||
const current = await get(input.task.taskId)
|
||||
if (!current.targets.includes(input.target)) throw notFound()
|
||||
return receiptView(
|
||||
await answer(
|
||||
activitiesUpdateActivity({
|
||||
client,
|
||||
path: { ...org, activity_id: current.id },
|
||||
body: {
|
||||
fields: { ...fieldsOf(current), subject: input.subject, body: input.body },
|
||||
revision: BigInt(input.expectedVersion),
|
||||
idempotency_key: input.key,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async change(input) {
|
||||
const operation = input.state === "done" ? activitiesCompleteTask : activitiesReopenTask
|
||||
return receiptView(
|
||||
await answer(
|
||||
operation({
|
||||
client,
|
||||
path: { ...org, activity_id: input.taskId },
|
||||
body: { revision: BigInt(input.expectedVersion), idempotency_key: input.key },
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async comment(input) {
|
||||
const result = await answer(
|
||||
activitiesAddComment({
|
||||
client,
|
||||
path: { ...org, activity_id: input.taskId },
|
||||
body: { body: input.body, revision: BigInt(input.expectedVersion), idempotency_key: input.key },
|
||||
}),
|
||||
)
|
||||
return { commentId: result.id, revision: result.revision.toString() }
|
||||
},
|
||||
}
|
||||
}
|
||||
export function activitiesServiceOf(container: Container, kind: "effect"): ActivitiesService
|
||||
export function activitiesServiceOf(container: Container, kind: ProcedureKind): ActivitiesReads
|
||||
export function activitiesServiceOf(container: Container, kind: ProcedureKind): ActivitiesReads {
|
||||
const service = container.has(ACTIVITIES_SERVICE)
|
||||
? container.resolve(ACTIVITIES_SERVICE)
|
||||
: sdkActivitiesService(requestBusinessBinding(container))
|
||||
return kind === "effect" ? service : readsOf(service)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Project-scoped task and comment contracts. Authority comes from resolved scope.
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
|
||||
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
|
||||
const id = z.string().uuid()
|
||||
const resourceSuffix = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
export const activityId = z.string().regex(new RegExp(`^act_${resourceSuffix}$`))
|
||||
const commentId = z.string().regex(new RegExp(`^cmt_${resourceSuffix}$`))
|
||||
// Cursors belong to the service and are forwarded unchanged; they are not resource IDs.
|
||||
const cursor = z.string().min(1).max(4096)
|
||||
const revision = z.string().regex(/^[1-9][0-9]{0,18}$/)
|
||||
export const taskView = z.object({
|
||||
taskId: activityId,
|
||||
subject: z.string(),
|
||||
body: z.string(),
|
||||
revision,
|
||||
status: z.enum(["open", "done", "cancelled"]),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
export type TaskView = z.output<typeof taskView>
|
||||
export const commentView = z.object({
|
||||
commentId,
|
||||
body: z.string(),
|
||||
author: z.string().nullable(),
|
||||
erased: z.boolean(),
|
||||
})
|
||||
export type CommentView = z.output<typeof commentView>
|
||||
export const commentReceipt = z.object({ commentId, revision })
|
||||
export type CommentReceipt = z.output<typeof commentReceipt>
|
||||
export const receipt = z.object({ taskId: activityId, revision })
|
||||
export type TaskReceipt = z.output<typeof receipt>
|
||||
const write = { ...inProject, taskId: activityId, expectedVersion: revision, requestId: id }
|
||||
export const taskList = procedure("activities.task.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("activities.task.read")
|
||||
.input(z.object({ ...inProject, status: z.enum(["open", "done", "cancelled"]).optional(), after: cursor.optional() }))
|
||||
.output(z.object({ rows: z.array(taskView), after: cursor.optional() }))
|
||||
.contract()
|
||||
export const taskGet = procedure("activities.task.get")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("activities.task.read")
|
||||
.input(z.object({ ...inProject, taskId: activityId }))
|
||||
.output(taskView)
|
||||
.contract()
|
||||
export const taskCreate = procedure("activities.task.create")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("activities.task.write")
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
subject: z.string().trim().min(1).max(200),
|
||||
body: z.string().max(10000).optional(),
|
||||
requestId: id,
|
||||
}),
|
||||
)
|
||||
.output(receipt)
|
||||
.contract()
|
||||
export const taskUpdate = procedure("activities.task.update")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("activities.task.write")
|
||||
.input(z.object({ ...write, subject: z.string().trim().min(1).max(200), body: z.string().max(10000) }))
|
||||
.output(receipt)
|
||||
.contract()
|
||||
export const taskComplete = procedure("activities.task.complete")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("activities.task.write")
|
||||
.input(z.object(write))
|
||||
.output(receipt)
|
||||
.contract()
|
||||
export const taskReopen = procedure("activities.task.reopen")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("activities.task.write")
|
||||
.input(z.object(write))
|
||||
.output(receipt)
|
||||
.contract()
|
||||
export const commentList = procedure("activities.comment.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("activities.task.read")
|
||||
.input(z.object({ ...inProject, taskId: activityId, after: cursor.optional() }))
|
||||
.output(z.object({ rows: z.array(commentView), after: cursor.optional() }))
|
||||
.contract()
|
||||
export const commentAdd = procedure("activities.comment.add")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("activities.comment.write")
|
||||
.input(z.object({ ...write, body: z.string().trim().min(1).max(10000) }))
|
||||
.output(commentReceipt)
|
||||
.contract()
|
||||
export const timeline = procedure("activities.timeline.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("activities.task.read")
|
||||
.input(z.object({ ...inProject, after: cursor.optional() }))
|
||||
.output(z.object({ rows: z.array(taskView), after: cursor.optional() }))
|
||||
.contract()
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { CommentReceipt, CommentView, TaskReceipt, TaskView } from "../contract/index.js"
|
||||
export interface Page<T> {
|
||||
rows: T[]
|
||||
after?: string
|
||||
}
|
||||
export interface TaskRecord extends TaskView {
|
||||
targets: string[]
|
||||
}
|
||||
export interface ActivitiesReads {
|
||||
readonly bindingId: string
|
||||
list(input: { target: string; status?: "open" | "done" | "cancelled"; after?: string }): Promise<Page<TaskView>>
|
||||
get(taskId: string): Promise<TaskRecord>
|
||||
comments(input: { taskId: string; after?: string }): Promise<Page<CommentView>>
|
||||
timeline(input: { target: string; after?: string }): Promise<Page<TaskView>>
|
||||
}
|
||||
export interface ActivitiesService extends ActivitiesReads {
|
||||
create(input: {
|
||||
subject: string
|
||||
body: string
|
||||
target: string
|
||||
participant: string
|
||||
key: string
|
||||
}): Promise<TaskReceipt>
|
||||
update(input: {
|
||||
task: TaskRecord
|
||||
target: string
|
||||
subject: string
|
||||
body: string
|
||||
expectedVersion: string
|
||||
key: string
|
||||
}): Promise<TaskReceipt>
|
||||
change(input: { taskId: string; state: "done" | "open"; expectedVersion: string; key: string }): Promise<TaskReceipt>
|
||||
comment(input: { taskId: string; body: string; expectedVersion: string; key: string }): Promise<CommentReceipt>
|
||||
}
|
||||
export const readsOf = (service: ActivitiesReads): ActivitiesReads => ({
|
||||
bindingId: service.bindingId,
|
||||
list: (input) => service.list(input),
|
||||
get: (id) => service.get(id),
|
||||
comments: (input) => service.comments(input),
|
||||
timeline: (input) => service.timeline(input),
|
||||
})
|
||||
export const ACTIVITIES_SERVICE = token<ActivitiesService>("activities.service", {
|
||||
views: { read: readsOf, write: readsOf },
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { actorRef, projectRef } from "@conusai-kit/business"
|
||||
import { conflict, notFound, type ProjectScope } from "@conusai-kit/core"
|
||||
import type { ActivitiesReads, ActivitiesService } from "./ports.js"
|
||||
|
||||
const keyOf = (scope: ProjectScope, operation: string, requestId: string) =>
|
||||
`activities:${scope.projectId}:${operation}:${requestId}`
|
||||
const targetOf = (service: ActivitiesReads, scope: ProjectScope) => projectRef(service.bindingId, scope.projectId)
|
||||
/** An app project is a narrower boundary than its organization partition. */
|
||||
export async function ownedTask(service: ActivitiesReads, scope: ProjectScope, taskId: string) {
|
||||
const task = await service.get(taskId)
|
||||
if (!task.targets.includes(targetOf(service, scope))) throw notFound()
|
||||
return task
|
||||
}
|
||||
function currentOrRetry(actual: string, requested: string) {
|
||||
// A future revision could become valid after a concurrent target move. A
|
||||
// prior revision must reach the service's idempotency check on a real retry.
|
||||
if (BigInt(requested) > BigInt(actual)) throw conflict("The task changed; reload before editing.")
|
||||
}
|
||||
export const listTasks = (
|
||||
service: ActivitiesReads,
|
||||
scope: ProjectScope,
|
||||
input: { status?: "open" | "done" | "cancelled" | undefined; after?: string | undefined },
|
||||
) =>
|
||||
service.list({
|
||||
target: targetOf(service, scope),
|
||||
status: input.status ?? "open",
|
||||
...(input.after ? { after: input.after } : {}),
|
||||
})
|
||||
export const projectTimeline = (service: ActivitiesReads, scope: ProjectScope, after?: string) =>
|
||||
service.timeline({ target: targetOf(service, scope), ...(after ? { after } : {}) })
|
||||
export const createTask = (
|
||||
service: ActivitiesService,
|
||||
scope: ProjectScope,
|
||||
input: { subject: string; body?: string | undefined; requestId: string },
|
||||
) =>
|
||||
service.create({
|
||||
subject: input.subject.trim(),
|
||||
body: input.body ?? "",
|
||||
target: targetOf(service, scope),
|
||||
participant: actorRef(service.bindingId, scope.actor.userId),
|
||||
key: keyOf(scope, "create", input.requestId),
|
||||
})
|
||||
export async function updateTask(
|
||||
service: ActivitiesService,
|
||||
scope: ProjectScope,
|
||||
input: { taskId: string; subject: string; body: string; expectedVersion: string; requestId: string },
|
||||
) {
|
||||
const task = await ownedTask(service, scope, input.taskId)
|
||||
currentOrRetry(task.revision, input.expectedVersion)
|
||||
return service.update({
|
||||
task,
|
||||
target: targetOf(service, scope),
|
||||
subject: input.subject.trim(),
|
||||
body: input.body,
|
||||
expectedVersion: input.expectedVersion,
|
||||
key: keyOf(scope, "update", input.requestId),
|
||||
})
|
||||
}
|
||||
export async function changeTask(
|
||||
service: ActivitiesService,
|
||||
scope: ProjectScope,
|
||||
input: { taskId: string; expectedVersion: string; requestId: string },
|
||||
state: "done" | "open",
|
||||
) {
|
||||
const task = await ownedTask(service, scope, input.taskId)
|
||||
currentOrRetry(task.revision, input.expectedVersion)
|
||||
return service.change({
|
||||
taskId: task.taskId,
|
||||
state,
|
||||
expectedVersion: input.expectedVersion,
|
||||
key: keyOf(scope, state, input.requestId),
|
||||
})
|
||||
}
|
||||
export async function listComments(
|
||||
service: ActivitiesReads,
|
||||
scope: ProjectScope,
|
||||
input: { taskId: string; after?: string | undefined },
|
||||
) {
|
||||
await ownedTask(service, scope, input.taskId)
|
||||
const comments = await service.comments({ taskId: input.taskId, ...(input.after ? { after: input.after } : {}) })
|
||||
await ownedTask(service, scope, input.taskId)
|
||||
return comments
|
||||
}
|
||||
export async function addComment(
|
||||
service: ActivitiesService,
|
||||
scope: ProjectScope,
|
||||
input: { taskId: string; body: string; expectedVersion: string; requestId: string },
|
||||
) {
|
||||
const task = await ownedTask(service, scope, input.taskId)
|
||||
currentOrRetry(task.revision, input.expectedVersion)
|
||||
return service.comment({
|
||||
taskId: input.taskId,
|
||||
body: input.body.trim(),
|
||||
expectedVersion: input.expectedVersion,
|
||||
key: keyOf(scope, "comment", input.requestId),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
export const en = {
|
||||
conflict: "The task changed. Reload before editing.",
|
||||
invalid: "Check the entered values and try again.",
|
||||
title: "Activities",
|
||||
intro: "Tasks and discussion for this project.",
|
||||
open: "Open tasks",
|
||||
timeline: "Task timeline",
|
||||
subject: "Subject",
|
||||
body: "Details",
|
||||
status: "Status",
|
||||
create: "Create task",
|
||||
update: "Save changes",
|
||||
complete: "Complete task",
|
||||
reopen: "Reopen task",
|
||||
comment: "Comment",
|
||||
addComment: "Add comment",
|
||||
comments: "Discussion",
|
||||
busy: "Saving…",
|
||||
empty: "No tasks yet.",
|
||||
emptyComments: "No comments yet.",
|
||||
erased: "Comment removed",
|
||||
back: "Back to activities",
|
||||
stateOpen: "Open",
|
||||
stateDone: "Done",
|
||||
stateCancelled: "Cancelled",
|
||||
unavailable: "Activities is temporarily unavailable. Try again later.",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
export const lt = {
|
||||
conflict: "Užduotis pasikeitė. Prieš redaguodami atnaujinkite puslapį.",
|
||||
invalid: "Patikrinkite įvestas reikšmes ir bandykite dar kartą.",
|
||||
title: "Veiklos",
|
||||
intro: "Šio projekto užduotys ir aptarimai.",
|
||||
open: "Atviros užduotys",
|
||||
timeline: "Užduočių eiga",
|
||||
subject: "Tema",
|
||||
body: "Aprašymas",
|
||||
status: "Būsena",
|
||||
create: "Sukurti užduotį",
|
||||
update: "Išsaugoti pakeitimus",
|
||||
complete: "Užbaigti užduotį",
|
||||
reopen: "Atverti užduotį",
|
||||
comment: "Komentaras",
|
||||
addComment: "Pridėti komentarą",
|
||||
comments: "Aptarimas",
|
||||
busy: "Išsaugoma…",
|
||||
empty: "Užduočių dar nėra.",
|
||||
emptyComments: "Komentarų dar nėra.",
|
||||
erased: "Komentaras pašalintas",
|
||||
back: "Grįžti į veiklas",
|
||||
stateOpen: "Atvira",
|
||||
stateDone: "Baigta",
|
||||
stateCancelled: "Atšaukta",
|
||||
unavailable: "Veiklų paslauga laikinai nepasiekiama. Bandykite vėliau.",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
export { sdkActivitiesService } from "./adapters/service.js"
|
||||
export * from "./contract/index.js"
|
||||
export { ACTIVITIES_SERVICE, type ActivitiesReads, type ActivitiesService } from "./core/ports.js"
|
||||
export { activitiesModule } from "./module.js"
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { ACTIVITIES_PROCEDURES } from "./adapters/procedures.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
const read = ["activities.activities:read"]
|
||||
const write = ["activities.activities:read", "activities.activities:write"]
|
||||
export const activitiesModule = defineModule({
|
||||
id: "activities",
|
||||
// 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"],
|
||||
permissions: {
|
||||
"activities.task.read": "viewer",
|
||||
"activities.task.write": "editor",
|
||||
"activities.comment.write": "editor",
|
||||
},
|
||||
procedures: ACTIVITIES_PROCEDURES,
|
||||
serviceRequirements: [
|
||||
{
|
||||
transport: "business",
|
||||
service: "activities",
|
||||
apiRange: "^1.1.0",
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
operations: {
|
||||
"activities.task.list": read,
|
||||
"activities.task.get": read,
|
||||
"activities.timeline.list": read,
|
||||
"activities.comment.list": read,
|
||||
"activities.task.create": write,
|
||||
"activities.task.update": write,
|
||||
"activities.task.complete": write,
|
||||
"activities.task.reopen": write,
|
||||
"activities.comment.add": ["activities.activities:read", "activities.comments:write"],
|
||||
},
|
||||
configuration: [],
|
||||
dependencies: [],
|
||||
dataClasses: ["personal", "operational"],
|
||||
partitionRequired: true,
|
||||
remediation: "/docs/business-services.md",
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
nav: [{ scope: "project", label: "activities.title", href: "activities", permission: "activities.task.read" }],
|
||||
},
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import {
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
ResourceList,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
useResourceList,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { useState } from "react"
|
||||
import type { CommentReceipt, CommentView, TaskReceipt, TaskView } from "../contract/index.js"
|
||||
import { describeFailure, type Labels } from "./labels.js"
|
||||
|
||||
type Page<T> = { rows: T[]; after?: string | undefined }
|
||||
export function Tasks({
|
||||
slugs,
|
||||
seed,
|
||||
href,
|
||||
labels,
|
||||
timeline = false,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page<TaskView>
|
||||
href: string
|
||||
labels: Labels
|
||||
timeline?: boolean
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<TaskView>({
|
||||
seed,
|
||||
rowId: (task) => task.taskId,
|
||||
read: async ({ after }) =>
|
||||
(
|
||||
await client.call<Page<TaskView>>(timeline ? "activities.timeline.list" : "activities.task.list", {
|
||||
...slugs,
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={timeline ? labels.timeline : labels.open}
|
||||
columns={[
|
||||
{ id: "subject", header: labels.subject, wrap: true },
|
||||
{
|
||||
id: "status",
|
||||
header: labels.status,
|
||||
cell: (task) =>
|
||||
task.status === "open"
|
||||
? labels.stateOpen
|
||||
: task.status === "done"
|
||||
? labels.stateDone
|
||||
: labels.stateCancelled,
|
||||
},
|
||||
]}
|
||||
rowHref={(task) => `${href}/${task.taskId}`}
|
||||
rowLabel={(task) => task.subject}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function TaskForm({
|
||||
slugs,
|
||||
task,
|
||||
href,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
task?: TaskView
|
||||
href: string
|
||||
labels: Labels
|
||||
}) {
|
||||
const navigation = usePageNavigation()
|
||||
const [requestId] = useState(() => crypto.randomUUID())
|
||||
const mutation = useWrite<Record<string, unknown>, TaskReceipt>(
|
||||
task ? "activities.task.update" : "activities.task.create",
|
||||
)
|
||||
const submit = async ({ subject, body = "" }: FormValues) => {
|
||||
const result = await mutation.mutate({
|
||||
...slugs,
|
||||
subject,
|
||||
body,
|
||||
requestId,
|
||||
...(task ? { taskId: task.taskId, expectedVersion: task.revision } : {}),
|
||||
})
|
||||
navigation.navigate(`${href}/${result.taskId}`)
|
||||
}
|
||||
return (
|
||||
<PageSection title={task ? labels.update : labels.create} variant="card">
|
||||
<FormLayout
|
||||
key={requestId}
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={task ? labels.update : labels.create}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<TextField name="subject" label={labels.subject} required maxLength={200} defaultValue={task?.subject} />
|
||||
<TextField name="body" label={labels.body} maxLength={10000} defaultValue={task?.body} />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function TaskState({ slugs, task, labels }: { slugs: ProjectSlugs; task: TaskView; labels: Labels }) {
|
||||
const navigation = usePageNavigation()
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const change = useWrite<Record<string, unknown>, TaskReceipt>(
|
||||
task.status === "done" ? "activities.task.reopen" : "activities.task.complete",
|
||||
)
|
||||
if (task.status === "cancelled") return null
|
||||
const submit = async () => {
|
||||
await change.mutate({ ...slugs, taskId: task.taskId, expectedVersion: task.revision, requestId })
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
}
|
||||
return (
|
||||
<FormLayout
|
||||
key={requestId}
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={task.status === "done" ? labels.reopen : labels.complete}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function Comments({
|
||||
slugs,
|
||||
task,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
task: TaskView
|
||||
seed: Page<CommentView>
|
||||
labels: Labels
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<CommentView>({
|
||||
seed,
|
||||
rowId: (comment) => comment.commentId,
|
||||
read: async ({ after }) =>
|
||||
(
|
||||
await client.call<Page<CommentView>>("activities.comment.list", {
|
||||
...slugs,
|
||||
taskId: task.taskId,
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.comments}
|
||||
columns={[
|
||||
{
|
||||
id: "body",
|
||||
header: labels.comment,
|
||||
wrap: true,
|
||||
cell: (comment) => (comment.erased ? labels.erased : comment.body),
|
||||
},
|
||||
]}
|
||||
rowLabel={(comment) => (comment.erased ? labels.erased : comment.body)}
|
||||
empty={{ title: labels.emptyComments }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function CommentForm({ slugs, task, labels }: { slugs: ProjectSlugs; task: TaskView; labels: Labels }) {
|
||||
const navigation = usePageNavigation()
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const add = useWrite<Record<string, unknown>, CommentReceipt>("activities.comment.add")
|
||||
const submit = async ({ body = "" }: FormValues) => {
|
||||
await add.mutate({ ...slugs, taskId: task.taskId, expectedVersion: task.revision, requestId, body })
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
}
|
||||
return (
|
||||
<PageSection title={labels.addComment}>
|
||||
<FormLayout
|
||||
key={requestId}
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={labels.addComment}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<TextField name="body" label={labels.comment} required maxLength={10000} />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import type { en } from "../i18n/en.js"
|
||||
export type Messages = { [K in keyof typeof en]: string }
|
||||
/**
|
||||
* What the loader hands the views: the module's messages and the kit's refusal sentences by problem code
|
||||
* (`businessRefusalNotices` of `@conusai-kit/business/refusals`, with this module's wording for a conflict and
|
||||
* an invalid entry).
|
||||
*/
|
||||
export type Labels = Messages & { readonly refusals: Readonly<Record<string, string>> }
|
||||
/** A remote detail never becomes untranslated or unbounded form text. */
|
||||
export function describeFailure(error: unknown, labels: Labels): string {
|
||||
return serviceNotice(error, labels.refusals)?.text ?? labels.unavailable
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
DetailLayout,
|
||||
Page,
|
||||
PageHeader,
|
||||
PageSection,
|
||||
type ServiceNotice,
|
||||
ServiceStatePage,
|
||||
serviceNotice,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import * as contracts from "../contract/index.js"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { CommentForm, Comments, TaskForm, TaskState, Tasks } from "./controls.js"
|
||||
import type { Labels, Messages } from "./labels.js"
|
||||
|
||||
type PageOf<T> = { rows: T[]; after?: string | undefined }
|
||||
type Data = { labels: Labels } & (
|
||||
| { kind: "list"; tasks: PageOf<contracts.TaskView>; timeline: PageOf<contracts.TaskView> }
|
||||
| { kind: "task"; task: contracts.TaskView; comments: PageOf<contracts.CommentView> }
|
||||
| { kind: "state"; notice: ServiceNotice }
|
||||
)
|
||||
export const activitiesPage: ProjectPage<TransportCaller, I18n, Container, Data> = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }) {
|
||||
const messages = Object.fromEntries(
|
||||
Object.keys(en).map((key) => [key, i18n.message(`activities.${key}`)]),
|
||||
) as Messages
|
||||
const labels: Labels = {
|
||||
...messages,
|
||||
refusals: businessRefusalNotices(i18n.locale, {
|
||||
conflict: messages.conflict,
|
||||
revision_conflict: messages.conflict,
|
||||
invalid: messages.invalid,
|
||||
}),
|
||||
}
|
||||
const [taskId, ...rest] = segments
|
||||
if (rest.length || (taskId !== undefined && !contracts.activityId.safeParse(taskId).success)) throw notFound()
|
||||
try {
|
||||
if (taskId === undefined) {
|
||||
const [tasks, timeline] = await Promise.all([
|
||||
caller.call(contracts.taskList, slugs),
|
||||
caller.call(contracts.timeline, slugs),
|
||||
])
|
||||
return { kind: "list", labels, tasks, timeline }
|
||||
}
|
||||
const [task, comments] = await Promise.all([
|
||||
caller.call(contracts.taskGet, { ...slugs, taskId }),
|
||||
caller.call(contracts.commentList, { ...slugs, taskId }),
|
||||
])
|
||||
return { kind: "task", labels, task, comments }
|
||||
} catch (error) {
|
||||
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
|
||||
if (!notice) throw error
|
||||
return { kind: "state", labels, notice }
|
||||
}
|
||||
},
|
||||
View({ slugs, data }) {
|
||||
const labels = data.labels
|
||||
const href = `/${slugs.org}/${slugs.workspace}/${slugs.project}/activities`
|
||||
if (data.kind === "state") return <ServiceStatePage title={labels.title} notice={data.notice} />
|
||||
if (data.kind === "list")
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={labels.title} description={labels.intro} />
|
||||
<DetailLayout
|
||||
aside={
|
||||
<Can permission="activities.task.write">
|
||||
<TaskForm slugs={slugs} href={href} labels={labels} />
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<PageSection title={labels.open}>
|
||||
<Tasks slugs={slugs} seed={data.tasks} href={href} labels={labels} />
|
||||
</PageSection>
|
||||
<PageSection title={labels.timeline}>
|
||||
<Tasks slugs={slugs} seed={data.timeline} href={href} labels={labels} timeline />
|
||||
</PageSection>
|
||||
</DetailLayout>
|
||||
</Page>
|
||||
)
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.task.subject} description={data.task.body} />
|
||||
<a href={href}>{labels.back}</a>
|
||||
<Can permission="activities.task.write">
|
||||
<TaskState slugs={slugs} task={data.task} labels={labels} />
|
||||
<TaskForm slugs={slugs} task={data.task} href={href} labels={labels} />
|
||||
</Can>
|
||||
<PageSection title={labels.comments}>
|
||||
<Comments slugs={slugs} task={data.task} seed={data.comments} labels={labels} />
|
||||
</PageSection>
|
||||
<Can permission="activities.comment.write">
|
||||
<CommentForm slugs={slugs} task={data.task} labels={labels} />
|
||||
</Can>
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user