@talkpilot/core-db 1.3.81 → 1.3.82
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 +1 -28
- package/dist/municipal/muniIssues/index.d.ts +2 -2
- package/dist/municipal/muniIssues/index.d.ts.map +1 -1
- package/dist/municipal/muniIssues/index.js +3 -1
- package/dist/municipal/muniIssues/index.js.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.constants.d.ts +4 -0
- package/dist/municipal/muniIssues/muniIssues.constants.d.ts.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.constants.js +10 -1
- package/dist/municipal/muniIssues/muniIssues.constants.js.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.getters.d.ts +6 -1
- package/dist/municipal/muniIssues/muniIssues.getters.d.ts.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.getters.js +31 -1
- package/dist/municipal/muniIssues/muniIssues.getters.js.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.schema.d.ts.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.schema.js +18 -0
- package/dist/municipal/muniIssues/muniIssues.schema.js.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.setters.d.ts +9 -1
- package/dist/municipal/muniIssues/muniIssues.setters.d.ts.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.setters.js +52 -2
- package/dist/municipal/muniIssues/muniIssues.setters.js.map +1 -1
- package/dist/municipal/muniIssues/muniIssues.types.d.ts +24 -2
- package/dist/municipal/muniIssues/muniIssues.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.constants.d.ts +0 -7
- package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.constants.js +1 -8
- package/dist/talkpilot/calls/calls.constants.js.map +1 -1
- package/dist/talkpilot/calls/calls.getters.d.ts +1 -2
- package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.getters.js +1 -6
- package/dist/talkpilot/calls/calls.getters.js.map +1 -1
- package/dist/talkpilot/calls/calls.types.d.ts +2 -4
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/municipal/muniIssues/__tests__/muniIssues.getters.spec.ts +66 -0
- package/src/municipal/muniIssues/__tests__/muniIssues.setters.spec.ts +106 -1
- package/src/municipal/muniIssues/__tests__/muniIssues.setup.spec.ts +1 -0
- package/src/municipal/muniIssues/index.ts +6 -0
- package/src/municipal/muniIssues/muniIssues.constants.ts +11 -0
- package/src/municipal/muniIssues/muniIssues.getters.ts +46 -2
- package/src/municipal/muniIssues/muniIssues.schema.ts +20 -0
- package/src/municipal/muniIssues/muniIssues.setters.ts +72 -3
- package/src/municipal/muniIssues/muniIssues.types.ts +36 -4
- package/src/talkpilot/calls/__tests__/calls.spec.ts +0 -48
- package/src/talkpilot/calls/calls.constants.ts +0 -8
- package/src/talkpilot/calls/calls.getters.ts +1 -19
- package/src/talkpilot/calls/calls.types.ts +1 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"calls.types.d.ts","sourceRoot":"","sources":["../../../src/talkpilot/calls/calls.types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,OAAO,EACL,
|
|
1
|
+
{"version":3,"file":"calls.types.d.ts","sourceRoot":"","sources":["../../../src/talkpilot/calls/calls.types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ObjectId } from "mongodb";
|
|
2
|
+
import { createMuniIssue, setMuniIssueResponse } from "../muniIssues.setters";
|
|
3
|
+
import {
|
|
4
|
+
countMuniIssuesByClient,
|
|
5
|
+
findMuniIssuesByClient,
|
|
6
|
+
generateMuniIssueId,
|
|
7
|
+
getMuniIssueByIdForClient,
|
|
8
|
+
getMuniIssuesCollection,
|
|
9
|
+
} from "../muniIssues.getters";
|
|
10
|
+
import { createMuniIssueInput } from "../../../test-utils/factories";
|
|
11
|
+
|
|
12
|
+
const CLIENT = "client-a";
|
|
13
|
+
|
|
14
|
+
const createIssue = async (clientId = CLIENT) =>
|
|
15
|
+
(await createMuniIssue(createMuniIssueInput({ clientId }), generateMuniIssueId())).toString();
|
|
16
|
+
|
|
17
|
+
describe("muniIssues getters (complaint tracking)", () => {
|
|
18
|
+
describe("findMuniIssuesByClient / countMuniIssuesByClient", () => {
|
|
19
|
+
it("should return only the client's tracked issues, newest first", async () => {
|
|
20
|
+
const older = await createIssue();
|
|
21
|
+
const newer = await createIssue();
|
|
22
|
+
// Both can land in the same millisecond; make the order explicit.
|
|
23
|
+
await getMuniIssuesCollection().updateOne(
|
|
24
|
+
{ _id: new ObjectId(older) },
|
|
25
|
+
{ $set: { createdAt: new Date(Date.now() - 60_000) } },
|
|
26
|
+
);
|
|
27
|
+
await createIssue("client-b");
|
|
28
|
+
// An issue created before complaint tracking has no status.
|
|
29
|
+
await getMuniIssuesCollection().insertOne({
|
|
30
|
+
...createMuniIssueInput({ clientId: CLIENT }),
|
|
31
|
+
_id: generateMuniIssueId(),
|
|
32
|
+
jira: { key: "CP-legacy", syncStatus: "created" },
|
|
33
|
+
createdAt: new Date(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const issues = await findMuniIssuesByClient(CLIENT);
|
|
37
|
+
|
|
38
|
+
expect(issues.map((i) => i._id.toString())).toEqual([newer, older]);
|
|
39
|
+
expect(await countMuniIssuesByClient(CLIENT)).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should filter by answered and apply skip/limit", async () => {
|
|
43
|
+
const answered = await createIssue();
|
|
44
|
+
await createIssue();
|
|
45
|
+
await createIssue();
|
|
46
|
+
await setMuniIssueResponse(answered, CLIENT, { status: "resolved", text: "Done" });
|
|
47
|
+
|
|
48
|
+
expect(await countMuniIssuesByClient(CLIENT, { answered: true })).toBe(1);
|
|
49
|
+
expect(await countMuniIssuesByClient(CLIENT, { answered: false })).toBe(2);
|
|
50
|
+
expect(
|
|
51
|
+
(await findMuniIssuesByClient(CLIENT, { answered: true }))[0]._id.toString(),
|
|
52
|
+
).toBe(answered);
|
|
53
|
+
expect(await findMuniIssuesByClient(CLIENT, { skip: 1, limit: 1 })).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getMuniIssueByIdForClient", () => {
|
|
58
|
+
it("should return the issue only for its own client", async () => {
|
|
59
|
+
const id = await createIssue();
|
|
60
|
+
|
|
61
|
+
expect((await getMuniIssueByIdForClient(id, CLIENT))?._id.toString()).toBe(id);
|
|
62
|
+
expect(await getMuniIssueByIdForClient(id, "client-b")).toBeNull();
|
|
63
|
+
expect(await getMuniIssueByIdForClient("not-a-valid-id", CLIENT)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createMuniIssue,
|
|
3
|
+
setMuniIssueResponse,
|
|
4
|
+
updateMuniIssueStatus,
|
|
5
|
+
} from "../muniIssues.setters";
|
|
2
6
|
import {
|
|
3
7
|
generateMuniIssueId,
|
|
4
8
|
getMuniIssueById,
|
|
@@ -25,6 +29,18 @@ describe("muniIssues setters", () => {
|
|
|
25
29
|
expect(saved?.createdAt).toBeInstanceOf(Date);
|
|
26
30
|
});
|
|
27
31
|
|
|
32
|
+
it("should start the issue as in_review with updatedAt and no response", async () => {
|
|
33
|
+
const insertedId = await createMuniIssue(
|
|
34
|
+
createMuniIssueInput(),
|
|
35
|
+
generateMuniIssueId(),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const saved = await getMuniIssueById(insertedId.toString());
|
|
39
|
+
expect(saved?.status).toBe("in_review");
|
|
40
|
+
expect(saved?.updatedAt).toBeInstanceOf(Date);
|
|
41
|
+
expect(saved?.response).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
28
44
|
it("should default jira.syncStatus to created when not provided", async () => {
|
|
29
45
|
const id = generateMuniIssueId();
|
|
30
46
|
|
|
@@ -91,4 +107,93 @@ describe("muniIssues setters", () => {
|
|
|
91
107
|
expect(await getMuniIssueByJiraKey("CP-nope")).toBeNull();
|
|
92
108
|
});
|
|
93
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("updateMuniIssueStatus", () => {
|
|
112
|
+
it("should change the status of an unanswered issue", async () => {
|
|
113
|
+
const input = createMuniIssueInput();
|
|
114
|
+
const id = (await createMuniIssue(input, generateMuniIssueId())).toString();
|
|
115
|
+
|
|
116
|
+
expect(await updateMuniIssueStatus(id, input.clientId, "in_progress")).toBe(true);
|
|
117
|
+
expect((await getMuniIssueById(id))?.status).toBe("in_progress");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should not touch another client's issue or a malformed id", async () => {
|
|
121
|
+
const id = (
|
|
122
|
+
await createMuniIssue(createMuniIssueInput(), generateMuniIssueId())
|
|
123
|
+
).toString();
|
|
124
|
+
|
|
125
|
+
expect(await updateMuniIssueStatus(id, "other-client", "resolved")).toBe(false);
|
|
126
|
+
expect(await updateMuniIssueStatus("not-a-valid-id", "any", "resolved")).toBe(false);
|
|
127
|
+
expect((await getMuniIssueById(id))?.status).toBe("in_review");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("setMuniIssueResponse", () => {
|
|
132
|
+
it("should save the response and status", async () => {
|
|
133
|
+
const input = createMuniIssueInput();
|
|
134
|
+
const id = (await createMuniIssue(input, generateMuniIssueId())).toString();
|
|
135
|
+
|
|
136
|
+
const saved = await setMuniIssueResponse(id, input.clientId, {
|
|
137
|
+
status: "resolved",
|
|
138
|
+
text: "Fixed",
|
|
139
|
+
imageUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(saved).toBe(true);
|
|
143
|
+
const issue = await getMuniIssueById(id);
|
|
144
|
+
expect(issue?.status).toBe("resolved");
|
|
145
|
+
expect(issue?.response).toMatchObject({
|
|
146
|
+
text: "Fixed",
|
|
147
|
+
imageUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
|
148
|
+
});
|
|
149
|
+
expect(issue?.response?.createdAt).toBeInstanceOf(Date);
|
|
150
|
+
expect(issue?.response?.updatedAt).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should let an answered issue be edited, keeping the first-sent date", async () => {
|
|
154
|
+
const input = createMuniIssueInput();
|
|
155
|
+
const id = (await createMuniIssue(input, generateMuniIssueId())).toString();
|
|
156
|
+
await setMuniIssueResponse(id, input.clientId, {
|
|
157
|
+
status: "in_review",
|
|
158
|
+
text: "First",
|
|
159
|
+
imageUrls: ["https://example.com/a.png"],
|
|
160
|
+
});
|
|
161
|
+
const firstSentAt = (await getMuniIssueById(id))?.response?.createdAt;
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
await setMuniIssueResponse(id, input.clientId, { status: "resolved", text: "Second" }),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
expect(await updateMuniIssueStatus(id, input.clientId, "no_issue_found")).toBe(true);
|
|
167
|
+
|
|
168
|
+
const issue = await getMuniIssueById(id);
|
|
169
|
+
expect(issue?.status).toBe("no_issue_found");
|
|
170
|
+
expect(issue?.response?.text).toBe("Second");
|
|
171
|
+
expect(issue?.response?.imageUrls).toEqual([]);
|
|
172
|
+
expect(issue?.response?.createdAt).toEqual(firstSentAt);
|
|
173
|
+
expect(issue?.response?.updatedAt).toBeInstanceOf(Date);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should store text starting with $ as plain text", async () => {
|
|
177
|
+
const input = createMuniIssueInput();
|
|
178
|
+
const id = (await createMuniIssue(input, generateMuniIssueId())).toString();
|
|
179
|
+
|
|
180
|
+
await setMuniIssueResponse(id, input.clientId, { status: "resolved", text: "$status" });
|
|
181
|
+
|
|
182
|
+
expect((await getMuniIssueById(id))?.response?.text).toBe("$status");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should not touch another client's issue or a malformed id", async () => {
|
|
186
|
+
const id = (
|
|
187
|
+
await createMuniIssue(createMuniIssueInput(), generateMuniIssueId())
|
|
188
|
+
).toString();
|
|
189
|
+
|
|
190
|
+
expect(
|
|
191
|
+
await setMuniIssueResponse(id, "other-client", { status: "resolved", text: "X" }),
|
|
192
|
+
).toBe(false);
|
|
193
|
+
expect(
|
|
194
|
+
await setMuniIssueResponse("not-a-valid-id", "any", { status: "resolved", text: "X" }),
|
|
195
|
+
).toBe(false);
|
|
196
|
+
expect((await getMuniIssueById(id))?.response).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
94
199
|
});
|
|
@@ -3,6 +3,8 @@ export * from "./muniIssues.setters";
|
|
|
3
3
|
export * from "./muniIssues.setup";
|
|
4
4
|
export {
|
|
5
5
|
MUNI_ISSUES_COLLECTION,
|
|
6
|
+
MUNI_ISSUE_MAX_RESPONSE_IMAGES,
|
|
7
|
+
MUNI_ISSUE_STATUSES,
|
|
6
8
|
MUNI_ISSUE_SYNC_STATUSES,
|
|
7
9
|
MUNI_ISSUE_TEXT_MAX_LENGTH,
|
|
8
10
|
MUNI_ISSUE_NAME_MAX_LENGTH,
|
|
@@ -13,5 +15,9 @@ export type {
|
|
|
13
15
|
MuniIssueContent,
|
|
14
16
|
MuniIssueJira,
|
|
15
17
|
MuniIssueSyncStatus,
|
|
18
|
+
MuniIssueStatus,
|
|
19
|
+
MuniIssueResponse,
|
|
16
20
|
CreateMuniIssueInput,
|
|
21
|
+
SetMuniIssueResponseInput,
|
|
22
|
+
FindMuniIssuesOptions,
|
|
17
23
|
} from "./muniIssues.types";
|
|
@@ -4,6 +4,17 @@ export const MUNI_ISSUES_COLLECTION = "muniIssues";
|
|
|
4
4
|
/** Sync state of the issue's Jira ticket at write time. */
|
|
5
5
|
export const MUNI_ISSUE_SYNC_STATUSES = ["created", "failed"] as const;
|
|
6
6
|
|
|
7
|
+
/** Customer-facing complaint status, set manually by the TalkPilot team. */
|
|
8
|
+
export const MUNI_ISSUE_STATUSES = [
|
|
9
|
+
"in_review",
|
|
10
|
+
"in_progress",
|
|
11
|
+
"resolved",
|
|
12
|
+
"no_issue_found",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/** Max images on a single TalkPilot response. */
|
|
16
|
+
export const MUNI_ISSUE_MAX_RESPONSE_IMAGES = 5;
|
|
17
|
+
|
|
7
18
|
/** Max length for the long free-text fields — well above the ~2000-char target. */
|
|
8
19
|
export const MUNI_ISSUE_TEXT_MAX_LENGTH = 5000;
|
|
9
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Collection, ObjectId } from "mongodb";
|
|
1
|
+
import { Collection, Filter, ObjectId } from "mongodb";
|
|
2
2
|
import { getDb } from "../index";
|
|
3
|
-
import { MuniIssue } from "./muniIssues.types";
|
|
3
|
+
import { FindMuniIssuesOptions, MuniIssue } from "./muniIssues.types";
|
|
4
4
|
import { MUNI_ISSUES_COLLECTION } from "./muniIssues.constants";
|
|
5
5
|
|
|
6
6
|
export const getMuniIssuesCollection = (): Collection<MuniIssue> => {
|
|
@@ -22,3 +22,47 @@ export const getMuniIssueByJiraKey = async (
|
|
|
22
22
|
): Promise<MuniIssue | null> => {
|
|
23
23
|
return getMuniIssuesCollection().findOne({ "jira.key": jiraKey });
|
|
24
24
|
};
|
|
25
|
+
|
|
26
|
+
// Tracked issues of one client. Issues without a status predate complaint tracking and are excluded.
|
|
27
|
+
const clientIssuesFilter = (
|
|
28
|
+
clientId: string,
|
|
29
|
+
answered?: boolean,
|
|
30
|
+
): Filter<MuniIssue> => ({
|
|
31
|
+
clientId,
|
|
32
|
+
status: { $exists: true },
|
|
33
|
+
...(answered !== undefined && { response: { $exists: answered } }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** A client's tracked issues, newest first. `limit` 0 means no limit (Mongo). */
|
|
37
|
+
export const findMuniIssuesByClient = async (
|
|
38
|
+
clientId: string,
|
|
39
|
+
{ answered, skip = 0, limit = 0 }: FindMuniIssuesOptions = {},
|
|
40
|
+
): Promise<MuniIssue[]> => {
|
|
41
|
+
return getMuniIssuesCollection()
|
|
42
|
+
.find(clientIssuesFilter(clientId, answered))
|
|
43
|
+
.sort({ createdAt: -1 })
|
|
44
|
+
.skip(skip)
|
|
45
|
+
.limit(limit)
|
|
46
|
+
.toArray();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const countMuniIssuesByClient = async (
|
|
50
|
+
clientId: string,
|
|
51
|
+
{ answered }: Pick<FindMuniIssuesOptions, "answered"> = {},
|
|
52
|
+
): Promise<number> => {
|
|
53
|
+
return getMuniIssuesCollection().countDocuments(
|
|
54
|
+
clientIssuesFilter(clientId, answered),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** One tracked issue, only if it belongs to the client. */
|
|
59
|
+
export const getMuniIssueByIdForClient = async (
|
|
60
|
+
id: string,
|
|
61
|
+
clientId: string,
|
|
62
|
+
): Promise<MuniIssue | null> => {
|
|
63
|
+
if (!ObjectId.isValid(id)) return null;
|
|
64
|
+
return getMuniIssuesCollection().findOne({
|
|
65
|
+
...clientIssuesFilter(clientId),
|
|
66
|
+
_id: new ObjectId(id),
|
|
67
|
+
});
|
|
68
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Document, IndexDescription } from "mongodb";
|
|
2
2
|
import {
|
|
3
|
+
MUNI_ISSUE_MAX_RESPONSE_IMAGES,
|
|
4
|
+
MUNI_ISSUE_STATUSES,
|
|
3
5
|
MUNI_ISSUE_SYNC_STATUSES,
|
|
4
6
|
MUNI_ISSUE_TEXT_MAX_LENGTH,
|
|
5
7
|
MUNI_ISSUE_NAME_MAX_LENGTH,
|
|
@@ -55,12 +57,30 @@ export const muniIssuesJsonSchema: Document = {
|
|
|
55
57
|
},
|
|
56
58
|
},
|
|
57
59
|
createdAt: { bsonType: "date" },
|
|
60
|
+
status: { enum: [...MUNI_ISSUE_STATUSES] },
|
|
61
|
+
response: {
|
|
62
|
+
bsonType: "object",
|
|
63
|
+
required: ["text", "createdAt"],
|
|
64
|
+
properties: {
|
|
65
|
+
text: { bsonType: "string", maxLength: MUNI_ISSUE_TEXT_MAX_LENGTH },
|
|
66
|
+
imageUrls: {
|
|
67
|
+
bsonType: "array",
|
|
68
|
+
maxItems: MUNI_ISSUE_MAX_RESPONSE_IMAGES,
|
|
69
|
+
items: { bsonType: "string" },
|
|
70
|
+
},
|
|
71
|
+
createdAt: { bsonType: "date" },
|
|
72
|
+
updatedAt: { bsonType: "date" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
updatedAt: { bsonType: "date" },
|
|
58
76
|
},
|
|
59
77
|
};
|
|
60
78
|
|
|
61
79
|
export const muniIssuesIndexes: IndexDescription[] = [
|
|
62
80
|
{ key: { callSid: 1 }, name: "callSid_1" },
|
|
63
81
|
{ key: { createdAt: -1 }, name: "createdAt_-1" },
|
|
82
|
+
// Complaint list: one municipality's issues, newest first.
|
|
83
|
+
{ key: { clientId: 1, createdAt: -1 }, name: "clientId_1_createdAt_-1" },
|
|
64
84
|
// Unique: one stored document per Jira ticket, guarding against duplicate writes.
|
|
65
85
|
{ key: { "jira.key": 1 }, name: "jira_key_unique", unique: true },
|
|
66
86
|
];
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { ObjectId } from "mongodb";
|
|
2
|
-
import {
|
|
1
|
+
import { Filter, ObjectId } from "mongodb";
|
|
2
|
+
import {
|
|
3
|
+
CreateMuniIssueInput,
|
|
4
|
+
MuniIssue,
|
|
5
|
+
MuniIssueStatus,
|
|
6
|
+
SetMuniIssueResponseInput,
|
|
7
|
+
} from "./muniIssues.types";
|
|
3
8
|
import { getMuniIssuesCollection } from "./muniIssues.getters";
|
|
4
9
|
import { ensureMuniIssuesCollectionOnce } from "./muniIssues.setup";
|
|
5
10
|
|
|
@@ -10,6 +15,7 @@ export const createMuniIssue = async (
|
|
|
10
15
|
): Promise<ObjectId> => {
|
|
11
16
|
await ensureMuniIssuesCollectionOnce();
|
|
12
17
|
|
|
18
|
+
const now = new Date();
|
|
13
19
|
const issue: MuniIssue = {
|
|
14
20
|
_id: id,
|
|
15
21
|
...issueData,
|
|
@@ -17,8 +23,71 @@ export const createMuniIssue = async (
|
|
|
17
23
|
...issueData.jira,
|
|
18
24
|
syncStatus: issueData.jira.syncStatus ?? "created",
|
|
19
25
|
},
|
|
20
|
-
|
|
26
|
+
status: "in_review",
|
|
27
|
+
createdAt: now,
|
|
28
|
+
updatedAt: now,
|
|
21
29
|
};
|
|
22
30
|
const { insertedId } = await getMuniIssuesCollection().insertOne(issue);
|
|
23
31
|
return insertedId;
|
|
24
32
|
};
|
|
33
|
+
|
|
34
|
+
// An issue of this client that is tracked (has a status); answered issues stay editable.
|
|
35
|
+
const trackedIssueFilter = (id: string, clientId: string): Filter<MuniIssue> => ({
|
|
36
|
+
_id: new ObjectId(id),
|
|
37
|
+
clientId,
|
|
38
|
+
status: { $exists: true },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Changes the status of an issue, answered or not. Returns false if none matched. */
|
|
42
|
+
export const updateMuniIssueStatus = async (
|
|
43
|
+
id: string,
|
|
44
|
+
clientId: string,
|
|
45
|
+
status: MuniIssueStatus,
|
|
46
|
+
): Promise<boolean> => {
|
|
47
|
+
if (!ObjectId.isValid(id)) return false;
|
|
48
|
+
await ensureMuniIssuesCollectionOnce();
|
|
49
|
+
|
|
50
|
+
const { matchedCount } = await getMuniIssuesCollection().updateOne(
|
|
51
|
+
trackedIssueFilter(id, clientId),
|
|
52
|
+
{ $set: { status, updatedAt: new Date() } },
|
|
53
|
+
);
|
|
54
|
+
return matchedCount > 0;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Saves the TalkPilot response, or replaces an existing one (text and images).
|
|
59
|
+
* Keeps the original `response.createdAt` and sets `response.updatedAt` on an edit.
|
|
60
|
+
* Returns false if none matched.
|
|
61
|
+
*/
|
|
62
|
+
export const setMuniIssueResponse = async (
|
|
63
|
+
id: string,
|
|
64
|
+
clientId: string,
|
|
65
|
+
{ status, text, imageUrls }: SetMuniIssueResponseInput,
|
|
66
|
+
): Promise<boolean> => {
|
|
67
|
+
if (!ObjectId.isValid(id)) return false;
|
|
68
|
+
await ensureMuniIssuesCollectionOnce();
|
|
69
|
+
|
|
70
|
+
const now = new Date();
|
|
71
|
+
// Pipeline update, so the first-sent date can be read from the stored response.
|
|
72
|
+
// $literal: user text starting with "$" must not be read as a field path.
|
|
73
|
+
const { matchedCount } = await getMuniIssuesCollection().updateOne(
|
|
74
|
+
trackedIssueFilter(id, clientId),
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
$set: {
|
|
78
|
+
status: { $literal: status },
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
response: {
|
|
81
|
+
text: { $literal: text },
|
|
82
|
+
imageUrls: { $literal: imageUrls ?? [] },
|
|
83
|
+
createdAt: { $ifNull: ["$response.createdAt", now] },
|
|
84
|
+
updatedAt: {
|
|
85
|
+
$cond: [{ $ifNull: ["$response.createdAt", false] }, now, "$$REMOVE"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
);
|
|
92
|
+
return matchedCount > 0;
|
|
93
|
+
};
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { ObjectId } from "mongodb";
|
|
2
2
|
|
|
3
3
|
import { CityName } from "../utils/types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
MUNI_ISSUE_STATUSES,
|
|
6
|
+
MUNI_ISSUE_SYNC_STATUSES,
|
|
7
|
+
} from "./muniIssues.constants";
|
|
5
8
|
|
|
6
9
|
export type MuniIssueSyncStatus = (typeof MUNI_ISSUE_SYNC_STATUSES)[number];
|
|
7
10
|
|
|
11
|
+
export type MuniIssueStatus = (typeof MUNI_ISSUE_STATUSES)[number];
|
|
12
|
+
|
|
13
|
+
/** The TalkPilot response. Editable: `createdAt` is when it was first sent, `updatedAt` the last edit. */
|
|
14
|
+
export type MuniIssueResponse = {
|
|
15
|
+
text: string;
|
|
16
|
+
imageUrls?: string[];
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
updatedAt?: Date;
|
|
19
|
+
};
|
|
20
|
+
|
|
8
21
|
export type MuniIssueContent = {
|
|
9
22
|
issuerName: string;
|
|
10
23
|
issuerRole: string;
|
|
@@ -25,13 +38,32 @@ export type MuniIssue = {
|
|
|
25
38
|
issueContent: MuniIssueContent;
|
|
26
39
|
jira: MuniIssueJira;
|
|
27
40
|
createdAt: Date;
|
|
41
|
+
// Complaint tracking (CP-1888). Optional: issues created before it have none
|
|
42
|
+
// of these, which is how they are kept out of tracking.
|
|
43
|
+
status?: MuniIssueStatus;
|
|
44
|
+
response?: MuniIssueResponse;
|
|
45
|
+
updatedAt?: Date;
|
|
28
46
|
};
|
|
29
47
|
|
|
30
|
-
// _id/createdAt are set on insert
|
|
31
|
-
// because the issue is only stored after its Jira
|
|
48
|
+
// _id/createdAt/status/updatedAt are set on insert, response only later (setMuniIssueResponse);
|
|
49
|
+
// jira.syncStatus defaults to "created" because the issue is only stored after its Jira
|
|
50
|
+
// ticket exists (CP-1115).
|
|
32
51
|
export type CreateMuniIssueInput = Omit<
|
|
33
52
|
MuniIssue,
|
|
34
|
-
"_id" | "createdAt" | "jira"
|
|
53
|
+
"_id" | "createdAt" | "jira" | "status" | "response" | "updatedAt"
|
|
35
54
|
> & {
|
|
36
55
|
jira: { key: string; syncStatus?: MuniIssueSyncStatus };
|
|
37
56
|
};
|
|
57
|
+
|
|
58
|
+
export type SetMuniIssueResponseInput = {
|
|
59
|
+
status: MuniIssueStatus;
|
|
60
|
+
text: string;
|
|
61
|
+
imageUrls?: string[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** `answered` filters by whether a TalkPilot response exists; omit it for all issues. */
|
|
65
|
+
export type FindMuniIssuesOptions = {
|
|
66
|
+
answered?: boolean;
|
|
67
|
+
skip?: number;
|
|
68
|
+
limit?: number;
|
|
69
|
+
};
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
pushToolExecution,
|
|
12
12
|
getToolExecutionsByCallSid,
|
|
13
13
|
updateCallStatusByCallSid,
|
|
14
|
-
setCallEndedBy,
|
|
15
14
|
} from "../calls.getters";
|
|
16
15
|
import type { ToolExecution, ToolExecutionStep } from "../calls.types";
|
|
17
16
|
import { ObjectId } from "mongodb";
|
|
@@ -75,53 +74,6 @@ describe("db.calls", () => {
|
|
|
75
74
|
});
|
|
76
75
|
});
|
|
77
76
|
|
|
78
|
-
describe("setCallEndedBy()", () => {
|
|
79
|
-
it("should set endedBy when it is not set yet", async () => {
|
|
80
|
-
const call = createOutGoingCallDoc();
|
|
81
|
-
await createCallDoc(call);
|
|
82
|
-
|
|
83
|
-
const written = await setCallEndedBy(call.callSid, "user");
|
|
84
|
-
|
|
85
|
-
expect(written).toBe(true);
|
|
86
|
-
expect(await getCallByCallSid(call.callSid)).toMatchObject({
|
|
87
|
-
endedBy: "user",
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("should keep the first value when called again", async () => {
|
|
92
|
-
const call = createOutGoingCallDoc();
|
|
93
|
-
await createCallDoc(call);
|
|
94
|
-
|
|
95
|
-
await setCallEndedBy(call.callSid, "user");
|
|
96
|
-
const written = await setCallEndedBy(call.callSid, "agent");
|
|
97
|
-
|
|
98
|
-
expect(written).toBe(false);
|
|
99
|
-
expect(await getCallByCallSid(call.callSid)).toMatchObject({
|
|
100
|
-
endedBy: "user",
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("should let only one of two concurrent writers win", async () => {
|
|
105
|
-
const call = createOutGoingCallDoc();
|
|
106
|
-
await createCallDoc(call);
|
|
107
|
-
|
|
108
|
-
const results = await Promise.all([
|
|
109
|
-
setCallEndedBy(call.callSid, "user"),
|
|
110
|
-
setCallEndedBy(call.callSid, "humanAgent"),
|
|
111
|
-
]);
|
|
112
|
-
|
|
113
|
-
expect(results.filter(Boolean)).toHaveLength(1);
|
|
114
|
-
const stored = await getCallByCallSid(call.callSid);
|
|
115
|
-
expect(stored?.endedBy).toBe(results[0] ? "user" : "humanAgent");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("should return false when the call does not exist", async () => {
|
|
119
|
-
await expect(setCallEndedBy("nonexistent-sid", "system")).resolves.toBe(
|
|
120
|
-
false,
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
77
|
describe("findCallsByQuery()", () => {
|
|
126
78
|
it("should find calls by status with limit and sort", async () => {
|
|
127
79
|
const clientId = "client123";
|
|
@@ -47,11 +47,3 @@ export const CallStatusValues = {
|
|
|
47
47
|
} as const;
|
|
48
48
|
|
|
49
49
|
export const ALL_CALL_STATUSES = Object.values(CallStatusValues);
|
|
50
|
-
|
|
51
|
-
/** Who ended a connected call: our platform, the AI agent (end flow), the caller, or a human agent. */
|
|
52
|
-
export const CallEndedByValues = {
|
|
53
|
-
System: "system",
|
|
54
|
-
Agent: "agent",
|
|
55
|
-
User: "user",
|
|
56
|
-
HumanAgent: "humanAgent",
|
|
57
|
-
} as const;
|
|
@@ -5,13 +5,7 @@ import {
|
|
|
5
5
|
CallUpdateParams,
|
|
6
6
|
getDb,
|
|
7
7
|
} from "../index";
|
|
8
|
-
import type {
|
|
9
|
-
CountOpts,
|
|
10
|
-
DateRange,
|
|
11
|
-
ToolExecution,
|
|
12
|
-
CallStatus,
|
|
13
|
-
CallEndedBy,
|
|
14
|
-
} from "./calls.types";
|
|
8
|
+
import type { CountOpts, DateRange, ToolExecution, CallStatus } from "./calls.types";
|
|
15
9
|
import {
|
|
16
10
|
TOOL_EXECUTIONS_DEFAULT_LIMIT,
|
|
17
11
|
TOOL_EXECUTIONS_DEFAULT_SKIP,
|
|
@@ -95,18 +89,6 @@ export const updateCallStatusByCallSid = async (
|
|
|
95
89
|
{ returnDocument: "after" },
|
|
96
90
|
);
|
|
97
91
|
|
|
98
|
-
|
|
99
|
-
export const setCallEndedBy = async (
|
|
100
|
-
callSid: string,
|
|
101
|
-
endedBy: CallEndedBy,
|
|
102
|
-
): Promise<boolean> => {
|
|
103
|
-
const result = await getCallsCollection().updateOne(
|
|
104
|
-
{ callSid, endedBy: { $exists: false } },
|
|
105
|
-
{ $set: { endedBy, updatedAt: new Date() } },
|
|
106
|
-
);
|
|
107
|
-
return result.modifiedCount > 0;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
92
|
export const pushToolExecution = async (
|
|
111
93
|
callSid: string,
|
|
112
94
|
execution: ToolExecution,
|
|
@@ -3,7 +3,6 @@ import { TranscriptionSegment } from "../results";
|
|
|
3
3
|
import { LeadProperty } from "../leads";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
CallEndedByValues,
|
|
7
6
|
CallStatusValues,
|
|
8
7
|
CONFERENCE_ROLE_CUSTOMER,
|
|
9
8
|
CONFERENCE_ROLE_SUPERVISOR,
|
|
@@ -16,8 +15,6 @@ export type ConferenceRole =
|
|
|
16
15
|
|
|
17
16
|
export type CallStatus = (typeof CallStatusValues)[keyof typeof CallStatusValues];
|
|
18
17
|
|
|
19
|
-
export type CallEndedBy = (typeof CallEndedByValues)[keyof typeof CallEndedByValues];
|
|
20
|
-
|
|
21
18
|
/**
|
|
22
19
|
* Represents the call usage information with details about charging segments and quota impact.
|
|
23
20
|
*/
|
|
@@ -52,7 +49,6 @@ export type Call = {
|
|
|
52
49
|
isAnsweredByAnsweringMachine?: boolean;
|
|
53
50
|
agentHungUp?: boolean;
|
|
54
51
|
endReason?: string;
|
|
55
|
-
endedBy?: CallEndedBy;
|
|
56
52
|
conferenceName?: string | null;
|
|
57
53
|
conferenceSid?: string | null;
|
|
58
54
|
conferenceParticipantSid?: string | null;
|
|
@@ -150,7 +146,7 @@ export type ImmutableCallFields =
|
|
|
150
146
|
| "env"
|
|
151
147
|
| "createdAt";
|
|
152
148
|
|
|
153
|
-
export type CallUpdateParams = Partial<Omit<Call, ImmutableCallFields
|
|
149
|
+
export type CallUpdateParams = Partial<Omit<Call, ImmutableCallFields>>;
|
|
154
150
|
|
|
155
151
|
export type CallDoc = WithId<Call>;
|
|
156
152
|
|