@pithy-sh/support 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/audit/actions.d.ts +9 -0
- package/dist/audit/actions.d.ts.map +1 -1
- package/dist/audit/actions.js +9 -0
- package/dist/capability.d.ts +2 -1
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +30 -6
- package/dist/config/config.d.ts +63 -2
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +63 -5
- package/dist/data/assignment.d.ts +43 -0
- package/dist/data/assignment.d.ts.map +1 -0
- package/dist/data/assignment.js +38 -0
- package/dist/data/embedding.d.ts +21 -0
- package/dist/data/embedding.d.ts.map +1 -0
- package/dist/data/embedding.js +23 -0
- package/dist/data/tables.d.ts +28 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/tables.js +10 -2
- package/dist/data/thread.d.ts +9 -4
- package/dist/data/thread.d.ts.map +1 -1
- package/dist/data/thread.js +9 -4
- package/dist/http/guards.d.ts +32 -14
- package/dist/http/guards.d.ts.map +1 -1
- package/dist/http/guards.js +44 -16
- package/dist/http/handlers.d.ts +35 -2
- package/dist/http/handlers.d.ts.map +1 -1
- package/dist/http/handlers.js +81 -8
- package/dist/http/resolve.d.ts +8 -0
- package/dist/http/resolve.d.ts.map +1 -1
- package/dist/http/resolve.js +20 -1
- package/dist/http/responses.d.ts +355 -0
- package/dist/http/responses.d.ts.map +1 -1
- package/dist/http/responses.js +64 -2
- package/dist/http/routes.d.ts +25 -11
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +34 -5
- package/dist/http/schemas.d.ts +67 -3
- package/dist/http/schemas.d.ts.map +1 -1
- package/dist/http/schemas.js +44 -5
- package/dist/http/scopes.d.ts +10 -5
- package/dist/http/scopes.d.ts.map +1 -1
- package/dist/http/scopes.js +42 -6
- package/dist/http/views.d.ts +9 -1
- package/dist/http/views.d.ts.map +1 -1
- package/dist/http/views.js +40 -1
- package/dist/inbound/ingest.d.ts +1 -1
- package/dist/inbound/ingest.d.ts.map +1 -1
- package/dist/inbound/ingest.js +3 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/migrations/0002_assignment.d.ts +20 -0
- package/dist/migrations/0002_assignment.d.ts.map +1 -0
- package/dist/migrations/0002_assignment.js +45 -0
- package/dist/migrations/0003_embeddings.d.ts +15 -0
- package/dist/migrations/0003_embeddings.d.ts.map +1 -0
- package/dist/migrations/0003_embeddings.js +23 -0
- package/dist/migrations/set.d.ts +13 -0
- package/dist/migrations/set.d.ts.map +1 -0
- package/dist/migrations/set.js +21 -0
- package/dist/presence/room.d.ts +35 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +66 -0
- package/dist/provision/provisionSupport.d.ts +32 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -1
- package/dist/provision/provisionSupport.js +10 -2
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
- package/dist/provision/resolveSupportConfig.js +13 -2
- package/dist/purge/contribution.d.ts +49 -0
- package/dist/purge/contribution.d.ts.map +1 -0
- package/dist/purge/contribution.js +164 -0
- package/dist/secret/registry.d.ts +26 -2
- package/dist/secret/registry.d.ts.map +1 -1
- package/dist/secret/registry.js +15 -3
- package/dist/semantic/documents.d.ts +21 -0
- package/dist/semantic/documents.d.ts.map +1 -0
- package/dist/semantic/documents.js +128 -0
- package/dist/semantic/embed.d.ts +59 -0
- package/dist/semantic/embed.d.ts.map +1 -0
- package/dist/semantic/embed.js +59 -0
- package/dist/semantic/messageIndex.d.ts +46 -0
- package/dist/semantic/messageIndex.d.ts.map +1 -0
- package/dist/semantic/messageIndex.js +67 -0
- package/dist/semantic/query.d.ts +37 -0
- package/dist/semantic/query.d.ts.map +1 -0
- package/dist/semantic/query.js +46 -0
- package/dist/staff/staff.d.ts +22 -0
- package/dist/staff/staff.d.ts.map +1 -0
- package/dist/staff/staff.js +64 -0
- package/dist/store/assignment.d.ts +63 -0
- package/dist/store/assignment.d.ts.map +1 -0
- package/dist/store/assignment.js +86 -0
- package/dist/store/hybrid.d.ts +134 -0
- package/dist/store/hybrid.d.ts.map +1 -0
- package/dist/store/hybrid.js +185 -0
- package/dist/store/paging.d.ts +10 -0
- package/dist/store/paging.d.ts.map +1 -1
- package/dist/store/paging.js +11 -1
- package/dist/store/provisionIndex.d.ts +21 -0
- package/dist/store/provisionIndex.d.ts.map +1 -0
- package/dist/store/provisionIndex.js +36 -0
- package/dist/store/search.d.ts +18 -9
- package/dist/store/search.d.ts.map +1 -1
- package/dist/store/search.js +36 -34
- package/dist/store/searchIndex.d.ts +4 -4
- package/dist/store/searchIndex.js +4 -4
- package/dist/store/threads.d.ts +174 -8
- package/dist/store/threads.d.ts.map +1 -1
- package/dist/store/threads.js +33 -40
- package/dist/submission/submit.d.ts +1 -1
- package/dist/submission/submit.d.ts.map +1 -1
- package/dist/submission/submit.js +2 -1
- package/dist/test-utils/seed.d.ts +33 -0
- package/dist/test-utils/seed.d.ts.map +1 -0
- package/dist/test-utils/seed.js +102 -0
- package/dist/test-utils/semantic.d.ts +21 -0
- package/dist/test-utils/semantic.d.ts.map +1 -0
- package/dist/test-utils/semantic.js +107 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflows/specs.d.ts +23 -0
- package/dist/workflows/specs.d.ts.map +1 -1
- package/dist/workflows/specs.js +27 -7
- package/dist/workflows/worker.d.ts +15 -2
- package/dist/workflows/worker.d.ts.map +1 -1
- package/dist/workflows/worker.js +48 -4
- package/package.json +9 -7
- package/pithy.manifest.json +21 -3
- package/src/audit/actions.ts +9 -0
- package/src/capability.ts +43 -4
- package/src/config/config.ts +104 -5
- package/src/data/assignment.ts +55 -0
- package/src/data/embedding.ts +27 -0
- package/src/data/tables.ts +8 -0
- package/src/data/thread.ts +26 -4
- package/src/http/guards.ts +52 -15
- package/src/http/handlers.ts +125 -4
- package/src/http/resolve.ts +19 -0
- package/src/http/responses.ts +154 -0
- package/src/http/routes.ts +98 -12
- package/src/http/schemas.ts +117 -28
- package/src/http/scopes.ts +43 -6
- package/src/http/views.ts +44 -0
- package/src/inbound/ingest.ts +5 -2
- package/src/index.ts +5 -0
- package/src/migrations/0002_assignment.ts +65 -0
- package/src/migrations/0003_embeddings.ts +29 -0
- package/src/migrations/set.ts +21 -0
- package/src/presence/room.ts +94 -0
- package/src/provision/provisionSupport.ts +33 -1
- package/src/provision/resolveSupportConfig.ts +24 -13
- package/src/purge/contribution.ts +294 -0
- package/src/secret/registry.ts +17 -2
- package/src/semantic/documents.ts +178 -0
- package/src/semantic/embed.ts +99 -0
- package/src/semantic/messageIndex.ts +74 -0
- package/src/semantic/query.ts +66 -0
- package/src/staff/staff.ts +128 -0
- package/src/store/assignment.ts +179 -0
- package/src/store/hybrid.ts +314 -0
- package/src/store/paging.ts +14 -0
- package/src/store/provisionIndex.ts +46 -0
- package/src/store/search.ts +58 -37
- package/src/store/searchIndex.ts +4 -4
- package/src/store/threads.ts +91 -91
- package/src/submission/submit.ts +3 -2
- package/src/test-utils/seed.ts +141 -0
- package/src/test-utils/semantic.ts +110 -0
- package/src/version.generated.ts +1 -1
- package/src/workflows/specs.ts +34 -0
- package/src/workflows/worker.ts +84 -8
- package/src/workflows/wrangler.jsonc +8 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type FeatureIdentity, featureVectorizeIndexName } from "@pithy-sh/core/src/naming/feature";
|
|
5
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
6
|
+
import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
|
|
7
|
+
import type { VectorIndexShape } from "@pithy-sh/vector/src/provision/provisionVector";
|
|
8
|
+
import { type SupportSearchConfig, supportSemantic } from "../config/config";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The semantic message index — what it is called, what shape it has, and what text goes into it.
|
|
12
|
+
*
|
|
13
|
+
* **Support owns it, through `@pithy-sh/vector`'s public modules, and the adopter composes no `vector()`.** The
|
|
14
|
+
* index is declared here as a {@link VectorIndexConfig}, the same value `vector()` builds from an adopter's
|
|
15
|
+
* `indexes` block, so `embedForIndex`, `queryVectors` and the reprocess orchestration take it unchanged and pin
|
|
16
|
+
* the model on writes and queries alike.
|
|
17
|
+
*
|
|
18
|
+
* **Vectors carry ids and nothing else.** A vector's id is the message's id, and no subject, body or address
|
|
19
|
+
* reaches Vectorize metadata: the text stays in D1 under the privacy markers its schema carries. A match is
|
|
20
|
+
* resolved back to its thread in D1, which is also where every filter applies.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The index's name in `pithy vector reprocess --index`, and the `<thing>` its Cloudflare name ends with. */
|
|
24
|
+
export const SUPPORT_MESSAGES_INDEX = "support-messages";
|
|
25
|
+
|
|
26
|
+
/** The Vectorize binding the index arrives on, in the app Worker and the classification worker alike. */
|
|
27
|
+
export const SUPPORT_VECTORIZE_BINDING = "SUPPORT_VECTORIZE";
|
|
28
|
+
|
|
29
|
+
/** The Workers AI binding a query and an inbound message are embedded on. */
|
|
30
|
+
export const SUPPORT_AI_BINDING = "AI";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How much of a message is embedded. A support message states its point early, and the rest is usually a
|
|
34
|
+
* quoted thread and a signature — text that would pull every vector toward the same neighborhood.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_EMBED_CHARS = 4000;
|
|
37
|
+
|
|
38
|
+
/** The index this search config asks for, or `undefined` when `search.semantic` is off. */
|
|
39
|
+
export function supportMessageIndex(search: SupportSearchConfig): VectorIndexConfig | undefined {
|
|
40
|
+
const semantic = supportSemantic(search);
|
|
41
|
+
if (!semantic) return undefined;
|
|
42
|
+
return {
|
|
43
|
+
model: semantic.model,
|
|
44
|
+
dimensions: semantic.dimensions,
|
|
45
|
+
// Text embeddings, compared by angle. Fixed at creation.
|
|
46
|
+
metric: "cosine",
|
|
47
|
+
binding: SUPPORT_VECTORIZE_BINDING,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The shape `pithy support provision` creates the index with — `vector`'s own provisioning shape, so a support
|
|
53
|
+
* index and a `vector()` index are created, and refused on a mismatch, by the same rule. `undefined` when off.
|
|
54
|
+
*/
|
|
55
|
+
export function supportMessageIndexShape(search: SupportSearchConfig): VectorIndexShape | undefined {
|
|
56
|
+
const index = supportMessageIndex(search);
|
|
57
|
+
return index ? { dimensions: index.dimensions, metric: index.metric } : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The index's Cloudflare name — `<project>-<env>-support-messages`, or a feature's own.
|
|
62
|
+
*
|
|
63
|
+
* Through the naming facade, which refuses a Vectorize name over 64 bytes rather than truncating it: a shortened
|
|
64
|
+
* name is a different, empty index.
|
|
65
|
+
*/
|
|
66
|
+
export function supportMessageIndexName(project: string, env: string, feature?: FeatureIdentity): string {
|
|
67
|
+
if (feature) return featureVectorizeIndexName(feature, SUPPORT_MESSAGES_INDEX);
|
|
68
|
+
return resourceNames(project).env(env).vectorizeIndex(SUPPORT_MESSAGES_INDEX);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The text one message is embedded as: its subject, then its body, bounded. */
|
|
72
|
+
export function embeddingText(subject: string, body: string): string {
|
|
73
|
+
return `${subject}\n\n${body}`.slice(0, MAX_EMBED_CHARS);
|
|
74
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
5
|
+
import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
|
|
6
|
+
import { embedForIndex, type VectorAi } from "@pithy-sh/vector/src/embed/embed";
|
|
7
|
+
import { queryVectors, type VectorStore } from "@pithy-sh/vector/src/index/index";
|
|
8
|
+
import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
|
|
9
|
+
import type { MessageHit } from "../store/search";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The semantic half of a search: embed the query with the index's pinned model, ask Vectorize for the nearest
|
|
13
|
+
* message ids, and resolve each to its thread in D1.
|
|
14
|
+
*
|
|
15
|
+
* **Ids without payload, so the ceiling is 100.** Vectorize allows 100 matches when it returns neither values
|
|
16
|
+
* nor metadata, and the vectors carry no metadata worth returning anyway. A thread is then scored by its best
|
|
17
|
+
* message, so a hundred messages roll up to the threads a page of results needs.
|
|
18
|
+
*
|
|
19
|
+
* A match whose message is gone — purged, say, before its vector was — resolves to nothing and is dropped.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many message ids the semantic side asks for: Vectorize's ceiling for a payload-free query. Stated here
|
|
24
|
+
* rather than imported, so support reaches only `vector`'s embed, index, provisioning and reprocess modules;
|
|
25
|
+
* `vectorImports.test.ts` holds it to `vector`'s own constant.
|
|
26
|
+
*/
|
|
27
|
+
export const SEMANTIC_TOP_K = 100;
|
|
28
|
+
|
|
29
|
+
/** What a semantic query needs. */
|
|
30
|
+
export interface SemanticQueryDeps {
|
|
31
|
+
/** The support tables, where each match is resolved to its thread. */
|
|
32
|
+
db: SupportDatabase;
|
|
33
|
+
/** The Workers AI binding. */
|
|
34
|
+
ai: VectorAi;
|
|
35
|
+
/** The message index's Vectorize binding. */
|
|
36
|
+
store: VectorStore;
|
|
37
|
+
/** The index's config. */
|
|
38
|
+
index: VectorIndexConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The messages nearest in meaning to `term`, best first, each with its raw similarity. */
|
|
42
|
+
export async function semanticHits(deps: SemanticQueryDeps, term: string): Promise<MessageHit[]> {
|
|
43
|
+
const [vector] = await embedForIndex(deps.ai, deps.index, [term]);
|
|
44
|
+
const result = await queryVectors(deps.store, deps.index, vector as number[], { topK: SEMANTIC_TOP_K });
|
|
45
|
+
if (result.matches.length === 0) return [];
|
|
46
|
+
|
|
47
|
+
const threadOf = new Map<string, string>();
|
|
48
|
+
for (const group of chunkByBoundParameters(
|
|
49
|
+
result.matches.map((match) => match.id),
|
|
50
|
+
0,
|
|
51
|
+
)) {
|
|
52
|
+
const rows = await deps.db
|
|
53
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
54
|
+
.select(["id", "threadId"])
|
|
55
|
+
.where("id", "in", group)
|
|
56
|
+
.execute();
|
|
57
|
+
for (const row of rows) threadOf.set(row.id, row.threadId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hits: MessageHit[] = [];
|
|
61
|
+
for (const match of result.matches) {
|
|
62
|
+
const threadId = threadOf.get(match.id);
|
|
63
|
+
if (threadId !== undefined) hits.push({ messageId: match.id, threadId, score: match.score });
|
|
64
|
+
}
|
|
65
|
+
return hits;
|
|
66
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ActingOrganization } from "@pithy-sh/core/src/access/acting";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { SupportStaffConfig } from "../config/config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* **Who of the app's own signed-in users works the inbox** (#816) — `support({ staff })`.
|
|
11
|
+
*
|
|
12
|
+
* Asked of core's acting-organization seam, which `@pithy-sh/organization` fills from a membership row
|
|
13
|
+
* read on this request; never imported, so without organization nobody is staff and every staff route
|
|
14
|
+
* refuses. The catalog is read by shape at compose, the way `artifacts` reads it, so support depends on
|
|
15
|
+
* no tenancy package.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The catalog support reads off a composed organization capability. Everything else about it is ignored. */
|
|
19
|
+
interface StaffCatalog {
|
|
20
|
+
readonly powers: readonly string[];
|
|
21
|
+
readonly roles: readonly string[];
|
|
22
|
+
readonly assignableRoles: readonly string[];
|
|
23
|
+
roleAllows(role: string, power: string): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A composed capability's role catalog, read by shape, or undefined when it carries none. */
|
|
27
|
+
function catalogOf(capability: Capability): StaffCatalog | undefined {
|
|
28
|
+
const catalog = (capability as { organizationRoles?: Partial<StaffCatalog> }).organizationRoles;
|
|
29
|
+
if (
|
|
30
|
+
!catalog ||
|
|
31
|
+
!Array.isArray(catalog.powers) ||
|
|
32
|
+
!Array.isArray(catalog.roles) ||
|
|
33
|
+
!Array.isArray(catalog.assignableRoles) ||
|
|
34
|
+
typeof catalog.roleAllows !== "function"
|
|
35
|
+
) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return catalog as StaffCatalog;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The role a composed ownership transfer confers, read by shape, or undefined where there is no transfer.
|
|
43
|
+
*
|
|
44
|
+
* Unassignable, always — organization refuses a transfer onto an assignable role — and still self-served:
|
|
45
|
+
* anybody may found an organization, every new organization starts with no holder, and with no holder an
|
|
46
|
+
* administrator may nominate themselves and accept. So an unassignable role is out of an organization's
|
|
47
|
+
* own reach only when it is not this one.
|
|
48
|
+
*/
|
|
49
|
+
function ownershipRoleOf(capability: Capability): string | undefined {
|
|
50
|
+
const confers = (capability as { organizationOwnership?: { confers?: unknown } }).organizationOwnership?.confers;
|
|
51
|
+
return typeof confers === "string" ? confers : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Refuse a staff composition at boot, naming what is wrong and what to change. */
|
|
55
|
+
function refuse(message: string, action: string, detail: string): never {
|
|
56
|
+
throw new InternalError({ message, action, detail });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuse, at compose, a staff block no request could be served by safely.
|
|
61
|
+
*
|
|
62
|
+
* Four refusals. **No organization**: a power is held in an organization, so with none composed nobody
|
|
63
|
+
* could ever be staff and the block is a mistake rather than a policy. **A power the catalog does not
|
|
64
|
+
* declare, or no role holds**: the same, by a typo. **A power on an assignable role**: a role is the
|
|
65
|
+
* catalog's and not one organization's, and an organization's own administrator may give any assignable
|
|
66
|
+
* role to their own members — so the power would make every customer's administrator able to read every
|
|
67
|
+
* other customer's support mail. **A power on the role ownership transfer confers**: unassignable, and
|
|
68
|
+
* still one any signed-in user can take — found an organization, nominate yourself, accept — so it opens
|
|
69
|
+
* the same inbox to the same people. On any other unassignable role it is conferred through the control
|
|
70
|
+
* plane alone, by an operator.
|
|
71
|
+
*/
|
|
72
|
+
export function assertStaffComposition(
|
|
73
|
+
staff: SupportStaffConfig | undefined,
|
|
74
|
+
capabilities: readonly Capability[],
|
|
75
|
+
): void {
|
|
76
|
+
if (!staff) return;
|
|
77
|
+
const tenancy = capabilities.find((candidate) => candidate.name === "organization");
|
|
78
|
+
const catalog = tenancy ? catalogOf(tenancy) : undefined;
|
|
79
|
+
if (!catalog) {
|
|
80
|
+
refuse(
|
|
81
|
+
"support() names staff powers, and organization is not composed.",
|
|
82
|
+
"Compose organization() with a role catalog that declares them, or remove `staff` from support().",
|
|
83
|
+
"a staff power is held in an organization; with none composed nobody could ever hold one",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const ownership = tenancy ? ownershipRoleOf(tenancy) : undefined;
|
|
87
|
+
for (const power of [staff.readPower, staff.assignPower]) {
|
|
88
|
+
if (!catalog.powers.includes(power)) {
|
|
89
|
+
refuse(
|
|
90
|
+
`support() names staff power \`${power}\`, which your role catalog does not declare.`,
|
|
91
|
+
"Declare it in defineRoles({ powers }) and give it to an unassignable role your staff hold, other than the one ownership transfer confers.",
|
|
92
|
+
`declared powers: ${catalog.powers.join(", ")}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const holders = catalog.roles.filter((role) => catalog.roleAllows(role, power));
|
|
96
|
+
if (holders.length === 0) {
|
|
97
|
+
refuse(
|
|
98
|
+
`No role in your catalog holds support's staff power \`${power}\`.`,
|
|
99
|
+
"Give it to an unassignable role your staff hold, other than the one ownership transfer confers.",
|
|
100
|
+
`power ${power} is declared and held by no role`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const handable = holders.filter((role) => catalog.assignableRoles.includes(role));
|
|
104
|
+
if (handable.length > 0) {
|
|
105
|
+
refuse(
|
|
106
|
+
`Support's staff power \`${power}\` is on a role an organization may hand out: ${handable.join(", ")}.`,
|
|
107
|
+
"Move it to a role listed in defineRoles({ unassignable }) that ownership transfer does not confer, so only an operator can confer it.",
|
|
108
|
+
`an organization administrator may assign ${handable.join(", ")} to their own members, which would open every customer's support mail to them`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (ownership !== undefined && holders.includes(ownership)) {
|
|
112
|
+
refuse(
|
|
113
|
+
`Support's staff power \`${power}\` is on \`${ownership}\`, the role ownership transfer confers, which an organization's founder may take alone.`,
|
|
114
|
+
"Move it to a role listed in defineRoles({ unassignable }) that ownership transfer does not confer, so only an operator can confer it.",
|
|
115
|
+
`anybody may found an organization and nominate themselves as its ${ownership}, which would open every customer's support mail to them`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether the organization this request acts for gives the caller one of support's staff powers. */
|
|
122
|
+
export function staffMay(
|
|
123
|
+
acting: ActingOrganization | null,
|
|
124
|
+
staff: SupportStaffConfig | undefined,
|
|
125
|
+
power: keyof SupportStaffConfig,
|
|
126
|
+
): boolean {
|
|
127
|
+
return acting !== null && staff !== undefined && acting.roleAllows(staff[power]);
|
|
128
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
|
|
5
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { type CompiledQuery, type SqlBool, sql } from "kysely";
|
|
7
|
+
import { SupportAssignment, type SupportAssignmentActorType } from "../data/assignment";
|
|
8
|
+
import {
|
|
9
|
+
SUPPORT_ASSIGNMENTS_TABLE,
|
|
10
|
+
SUPPORT_THREADS_TABLE,
|
|
11
|
+
type SupportDatabase,
|
|
12
|
+
supportDatabase,
|
|
13
|
+
} from "../data/tables";
|
|
14
|
+
import { SupportThread } from "../data/thread";
|
|
15
|
+
import { SupportNotFoundError } from "../error/errors";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Who has a thread (#816).
|
|
19
|
+
*
|
|
20
|
+
* **One fact, not a state machine.** A thread has an assignee or it has none, and any change moves it
|
|
21
|
+
* straight to the next one. Every real change appends a row to `pithy_support_assignments`; a change that
|
|
22
|
+
* changes nothing — the current assignee again, or unassigning a thread nobody has — writes nothing at
|
|
23
|
+
* all, which is what lets a caller emit its audit event only when something happened.
|
|
24
|
+
*
|
|
25
|
+
* **Compare-and-set, not last-write-wins on a read.** The previous assignee is part of what the history and
|
|
26
|
+
* the audit event record, so the write is conditioned on the assignee this call read. Two handovers racing
|
|
27
|
+
* from one state cannot both claim to have replaced the same person: the loser re-reads and replaces
|
|
28
|
+
* whoever won, and the chain of `previousRef` stays unbroken.
|
|
29
|
+
*
|
|
30
|
+
* **The change and its history row land together or not at all.** The conditional update and the history
|
|
31
|
+
* insert go to D1 as one batch, which D1 runs as one transaction, and the insert is guarded on the update
|
|
32
|
+
* having changed a row. A history row that cannot be written rolls the assignee back with it, so a retry
|
|
33
|
+
* sees the old assignee, makes the change again, and records it — rather than finding the change already
|
|
34
|
+
* made, taking the no-op branch, and leaving that handover out of the history and the audit trail for good.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Somebody a thread may be assigned to: an opaque reference and how to show it. */
|
|
38
|
+
export interface Assignee {
|
|
39
|
+
/** Opaque — a dashboard member, a user of the app, an agent. Support never interprets it. */
|
|
40
|
+
ref: string;
|
|
41
|
+
/** Display text, as the assigner named them. */
|
|
42
|
+
name: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What {@link assignThread} takes. */
|
|
46
|
+
export interface AssignThreadOptions {
|
|
47
|
+
/** The thread. */
|
|
48
|
+
threadId: string;
|
|
49
|
+
/** Who should have it, or null for nobody. */
|
|
50
|
+
assignee: Assignee | null;
|
|
51
|
+
/** How the assigner was verified. */
|
|
52
|
+
actorType: SupportAssignmentActorType;
|
|
53
|
+
/** The control-plane subject, or the signed-in user's id. */
|
|
54
|
+
assignedBy: string;
|
|
55
|
+
/** Why, when the assigner said. */
|
|
56
|
+
note?: string;
|
|
57
|
+
/** Generate a history row id. */
|
|
58
|
+
newId: () => string;
|
|
59
|
+
/** Now. */
|
|
60
|
+
now: Date;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What {@link assignThread} did. */
|
|
64
|
+
export interface AssignThreadResult {
|
|
65
|
+
/** The thread as it now stands. */
|
|
66
|
+
thread: SupportThread;
|
|
67
|
+
/** Whether anything changed. False for the current assignee again, or for unassigning nobody. */
|
|
68
|
+
changed: boolean;
|
|
69
|
+
/** Who had it before this call, or null when nobody did. */
|
|
70
|
+
previous: Assignee | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** How many times a write lost to a concurrent one is retried before the request gives up. */
|
|
74
|
+
const MAX_ATTEMPTS = 5;
|
|
75
|
+
|
|
76
|
+
/** The assignee a thread row names, or null. */
|
|
77
|
+
function assigneeOf(thread: SupportThread): Assignee | null {
|
|
78
|
+
return thread.assigneeRef ? { ref: thread.assigneeRef, name: thread.assigneeName ?? thread.assigneeRef } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A compiled Kysely query as a D1 statement, for a batch. */
|
|
82
|
+
function prepared(d1: D1Database, compiled: CompiledQuery): D1PreparedStatement {
|
|
83
|
+
return d1.prepare(compiled.sql).bind(...(compiled.parameters as unknown[]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Insert a history row only when the statement before it changed a row — `changes()` answers for the
|
|
88
|
+
* statement just before it in the batch — so a compare-and-set that matched nothing writes no history.
|
|
89
|
+
*/
|
|
90
|
+
function guardedHistory(db: SupportDatabase, row: Record<string, unknown>): CompiledQuery {
|
|
91
|
+
const columns = Object.keys(row);
|
|
92
|
+
return (
|
|
93
|
+
db
|
|
94
|
+
.insertInto(SUPPORT_ASSIGNMENTS_TABLE)
|
|
95
|
+
// biome-ignore lint/suspicious/noExplicitAny: the encoded row's own keys, which are the table's columns.
|
|
96
|
+
.columns(columns as any)
|
|
97
|
+
.expression(
|
|
98
|
+
db.selectNoFrom(columns.map((column) => sql.val(row[column]).as(column))).where(sql<SqlBool>`changes() > 0`),
|
|
99
|
+
)
|
|
100
|
+
.compile()
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Assign a thread, or unassign it. Throws `support/not_found` when it does not exist. */
|
|
105
|
+
export async function assignThread(d1: D1Database, options: AssignThreadOptions): Promise<AssignThreadResult> {
|
|
106
|
+
const db = supportDatabase(d1);
|
|
107
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
108
|
+
const row = await db
|
|
109
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
110
|
+
.selectAll()
|
|
111
|
+
.where("id", "=", options.threadId)
|
|
112
|
+
.executeTakeFirst();
|
|
113
|
+
if (!row) throw new SupportNotFoundError({ detail: `no support thread ${options.threadId}` });
|
|
114
|
+
const current = SupportThread.parse(row);
|
|
115
|
+
const previous = assigneeOf(current);
|
|
116
|
+
|
|
117
|
+
// The same ref is the same person, whatever name it arrives under: a no-op, so nothing is written,
|
|
118
|
+
// nothing is recorded, and the caller emits nothing.
|
|
119
|
+
if ((previous?.ref ?? null) === (options.assignee?.ref ?? null)) {
|
|
120
|
+
return { thread: current, changed: false, previous };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const now = options.now.getTime();
|
|
124
|
+
const next = {
|
|
125
|
+
assigneeRef: options.assignee?.ref ?? null,
|
|
126
|
+
assigneeName: options.assignee?.name ?? null,
|
|
127
|
+
assignedAt: options.assignee ? now : null,
|
|
128
|
+
assignedBy: options.assignee ? options.assignedBy : null,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
};
|
|
131
|
+
const update = db
|
|
132
|
+
.updateTable(SUPPORT_THREADS_TABLE)
|
|
133
|
+
.set(next)
|
|
134
|
+
.where("id", "=", options.threadId)
|
|
135
|
+
// `is`, so a null previous assignee compares as SQLite's null-safe equality rather than never matching.
|
|
136
|
+
.where("assigneeRef", "is", previous?.ref ?? null)
|
|
137
|
+
.compile();
|
|
138
|
+
const history = guardedHistory(
|
|
139
|
+
db,
|
|
140
|
+
SupportAssignment.encode({
|
|
141
|
+
id: options.newId(),
|
|
142
|
+
threadId: options.threadId,
|
|
143
|
+
assigneeRef: options.assignee?.ref ?? null,
|
|
144
|
+
assigneeName: options.assignee?.name ?? null,
|
|
145
|
+
previousRef: previous?.ref ?? null,
|
|
146
|
+
previousName: previous?.name ?? null,
|
|
147
|
+
actorType: options.actorType,
|
|
148
|
+
assignedBy: options.assignedBy,
|
|
149
|
+
note: options.note ?? null,
|
|
150
|
+
createdAt: options.now,
|
|
151
|
+
}) as Record<string, unknown>,
|
|
152
|
+
);
|
|
153
|
+
const [updated] = await d1.batch([prepared(d1, update), prepared(d1, history)]);
|
|
154
|
+
if ((updated?.meta.changes ?? 0) === 0) continue;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
thread: SupportThread.parse({ ...row, ...next }),
|
|
158
|
+
changed: true,
|
|
159
|
+
previous,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
throw new InternalError({
|
|
163
|
+
message: "That conversation is being reassigned by somebody else. Try again.",
|
|
164
|
+
detail: `assignment of thread ${options.threadId} lost ${MAX_ATTEMPTS} races in a row`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A thread's assignee history, oldest first. */
|
|
169
|
+
export async function listAssignments(db: SupportDatabase, threadId: string): Promise<SupportAssignment[]> {
|
|
170
|
+
const rows = await db
|
|
171
|
+
.selectFrom(SUPPORT_ASSIGNMENTS_TABLE)
|
|
172
|
+
.selectAll()
|
|
173
|
+
.where("threadId", "=", threadId)
|
|
174
|
+
.orderBy("createdAt", "asc")
|
|
175
|
+
// Insertion order breaks a tie within one millisecond, so the chain reads in the order it was written.
|
|
176
|
+
.orderBy(sql`rowid`, "asc")
|
|
177
|
+
.execute();
|
|
178
|
+
return rows.map((row) => SupportAssignment.parse(row));
|
|
179
|
+
}
|