ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+30
View File
@@ -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.
+128
View File
@@ -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ų.",
)
})
})
+27
View File
@@ -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)
})
})
+34
View File
@@ -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),
),
]
+201
View File
@@ -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)
}
+104
View File
@@ -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()
+46
View File
@@ -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 },
})
+98
View File
@@ -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),
})
}
+28
View File
@@ -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.",
}
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
export { en } from "./en.js"
export { lt } from "./lt.js"
+28
View File
@@ -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.",
}
+6
View File
@@ -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"
+49
View File
@@ -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 },
})
+193
View File
@@ -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>
)
}
+14
View File
@@ -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
}
+102
View File
@@ -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>
)
},
})