@talkpilot/core-db 1.3.80 → 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/AGENTS.md +1 -1
- package/README.md +4 -29
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/municipal/index.d.ts +1 -0
- package/dist/municipal/index.d.ts.map +1 -1
- package/dist/municipal/index.js +1 -0
- package/dist/municipal/index.js.map +1 -1
- 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/municipal/residents/index.d.ts +5 -0
- package/dist/municipal/residents/index.d.ts.map +1 -0
- package/dist/municipal/residents/index.js +24 -0
- package/dist/municipal/residents/index.js.map +1 -0
- package/dist/municipal/residents/residents.constants.d.ts +3 -0
- package/dist/municipal/residents/residents.constants.d.ts.map +1 -0
- package/dist/municipal/residents/residents.constants.js +6 -0
- package/dist/municipal/residents/residents.constants.js.map +1 -0
- package/dist/municipal/residents/residents.getters.d.ts +9 -0
- package/dist/municipal/residents/residents.getters.d.ts.map +1 -0
- package/dist/municipal/residents/residents.getters.js +32 -0
- package/dist/municipal/residents/residents.getters.js.map +1 -0
- package/dist/municipal/residents/residents.schema.d.ts +4 -0
- package/dist/municipal/residents/residents.schema.d.ts.map +1 -0
- package/dist/municipal/residents/residents.schema.js +29 -0
- package/dist/municipal/residents/residents.schema.js.map +1 -0
- package/dist/municipal/residents/residents.types.d.ts +15 -0
- package/dist/municipal/residents/residents.types.d.ts.map +1 -0
- package/dist/municipal/residents/residents.types.js +3 -0
- package/dist/municipal/residents/residents.types.js.map +1 -0
- 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/dist/talkpilot/flows/flows.schema.d.ts +28 -0
- package/dist/talkpilot/flows/flows.schema.d.ts.map +1 -1
- package/dist/talkpilot/flows/flows.schema.js +16 -0
- package/dist/talkpilot/flows/flows.schema.js.map +1 -1
- package/dist/talkpilot/flows/flows.types.d.ts +8 -0
- package/dist/talkpilot/flows/flows.types.d.ts.map +1 -1
- package/dist/test-utils/factories/index.d.ts +1 -0
- package/dist/test-utils/factories/index.d.ts.map +1 -1
- package/dist/test-utils/factories/index.js +1 -0
- package/dist/test-utils/factories/index.js.map +1 -1
- package/dist/test-utils/factories/municipal/residents.d.ts +5 -0
- package/dist/test-utils/factories/municipal/residents.d.ts.map +1 -0
- package/dist/test-utils/factories/municipal/residents.js +22 -0
- package/dist/test-utils/factories/municipal/residents.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/municipal/index.ts +1 -0
- 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/municipal/residents/__tests__/residents.spec.ts +92 -0
- package/src/municipal/residents/index.ts +4 -0
- package/src/municipal/residents/residents.constants.ts +2 -0
- package/src/municipal/residents/residents.getters.ts +42 -0
- package/src/municipal/residents/residents.schema.ts +28 -0
- package/src/municipal/residents/residents.types.ts +17 -0
- 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
- package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +28 -0
- package/src/talkpilot/flows/flows.schema.ts +16 -0
- package/src/talkpilot/flows/flows.types.ts +9 -0
- package/src/test-utils/factories/index.ts +1 -0
- package/src/test-utils/factories/municipal/residents.ts +20 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ObjectId } from "mongodb";
|
|
2
|
+
import {
|
|
3
|
+
findResidentsByCityAndPhone,
|
|
4
|
+
getResidentByCityAndIdNumber,
|
|
5
|
+
getResidentByCityPhoneAndIdNumber,
|
|
6
|
+
getResidentById,
|
|
7
|
+
getResidentsCollection,
|
|
8
|
+
} from "../residents.getters";
|
|
9
|
+
import { createResident } from "../../../test-utils/factories/municipal/residents";
|
|
10
|
+
|
|
11
|
+
const CITY = "tests" as const;
|
|
12
|
+
const OTHER_CITY = "eilat" as const;
|
|
13
|
+
|
|
14
|
+
describe("residents getters", () => {
|
|
15
|
+
it("getResidentById returns the document, or null for a missing / invalid id", async () => {
|
|
16
|
+
const resident = createResident({ cityName: CITY });
|
|
17
|
+
await getResidentsCollection().insertOne(resident);
|
|
18
|
+
|
|
19
|
+
const found = await getResidentById(resident._id.toHexString());
|
|
20
|
+
expect(found?._id.equals(resident._id)).toBe(true);
|
|
21
|
+
expect(found?.idNumber).toBe(resident.idNumber);
|
|
22
|
+
|
|
23
|
+
expect(await getResidentById("not-an-id")).toBeNull();
|
|
24
|
+
expect(await getResidentById(new ObjectId().toHexString())).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("findResidentsByCityAndPhone returns only matching city + phone", async () => {
|
|
28
|
+
const phone = "+972501111111";
|
|
29
|
+
const match = createResident({ cityName: CITY, phoneNumber: phone });
|
|
30
|
+
const samePhoneOtherCity = createResident({
|
|
31
|
+
cityName: OTHER_CITY,
|
|
32
|
+
phoneNumber: phone,
|
|
33
|
+
});
|
|
34
|
+
const sameCityOtherPhone = createResident({
|
|
35
|
+
cityName: CITY,
|
|
36
|
+
phoneNumber: "+972502222222",
|
|
37
|
+
});
|
|
38
|
+
await getResidentsCollection().insertMany([
|
|
39
|
+
match,
|
|
40
|
+
samePhoneOtherCity,
|
|
41
|
+
sameCityOtherPhone,
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const found = await findResidentsByCityAndPhone(CITY, phone);
|
|
45
|
+
expect(found).toHaveLength(1);
|
|
46
|
+
expect(found[0]._id.equals(match._id)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("allows a document with only _id", async () => {
|
|
50
|
+
const resident = { _id: new ObjectId() };
|
|
51
|
+
await getResidentsCollection().insertOne(resident);
|
|
52
|
+
|
|
53
|
+
const found = await getResidentById(resident._id.toHexString());
|
|
54
|
+
expect(found?._id.equals(resident._id)).toBe(true);
|
|
55
|
+
expect(found?.firstName).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("getResidentByCityAndIdNumber is scoped to the city", async () => {
|
|
59
|
+
const idNumber = "123456789";
|
|
60
|
+
const match = createResident({ cityName: CITY, idNumber });
|
|
61
|
+
const otherCity = createResident({ cityName: OTHER_CITY, idNumber: "987654321" });
|
|
62
|
+
await getResidentsCollection().insertMany([match, otherCity]);
|
|
63
|
+
|
|
64
|
+
const found = await getResidentByCityAndIdNumber(CITY, idNumber);
|
|
65
|
+
expect(found?._id.equals(match._id)).toBe(true);
|
|
66
|
+
|
|
67
|
+
expect(await getResidentByCityAndIdNumber(OTHER_CITY, idNumber)).toBeNull();
|
|
68
|
+
expect(await getResidentByCityAndIdNumber(CITY, "000000000")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("getResidentByCityPhoneAndIdNumber requires both phone and id in that city", async () => {
|
|
72
|
+
const phone = "+972501111111";
|
|
73
|
+
const idNumber = "201111111";
|
|
74
|
+
const match = createResident({ cityName: CITY, phoneNumber: phone, idNumber });
|
|
75
|
+
const samePhoneWrongId = createResident({
|
|
76
|
+
cityName: CITY,
|
|
77
|
+
phoneNumber: phone,
|
|
78
|
+
idNumber: "209999999",
|
|
79
|
+
});
|
|
80
|
+
await getResidentsCollection().insertMany([match, samePhoneWrongId]);
|
|
81
|
+
|
|
82
|
+
const found = await getResidentByCityPhoneAndIdNumber(CITY, phone, idNumber);
|
|
83
|
+
expect(found?._id.equals(match._id)).toBe(true);
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
await getResidentByCityPhoneAndIdNumber(CITY, phone, "000000000"),
|
|
87
|
+
).toBeNull();
|
|
88
|
+
expect(
|
|
89
|
+
await getResidentByCityPhoneAndIdNumber(OTHER_CITY, phone, idNumber),
|
|
90
|
+
).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Collection, Filter } from "mongodb";
|
|
2
|
+
import { CityName, getDb, ObjectId, Resident } from "../index";
|
|
3
|
+
import { RESIDENTS_COLLECTION } from "./residents.constants";
|
|
4
|
+
|
|
5
|
+
export const getResidentsCollection = (): Collection<Resident> => {
|
|
6
|
+
return getDb().collection<Resident>(RESIDENTS_COLLECTION);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const findResidents = async (
|
|
10
|
+
filter: Filter<Resident> = {},
|
|
11
|
+
): Promise<Resident[]> => {
|
|
12
|
+
return getResidentsCollection().find(filter).toArray();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const getResidentById = async (
|
|
16
|
+
residentId: string,
|
|
17
|
+
): Promise<Resident | null> => {
|
|
18
|
+
if (!ObjectId.isValid(residentId)) return null;
|
|
19
|
+
return getResidentsCollection().findOne({ _id: new ObjectId(residentId) });
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const findResidentsByCityAndPhone = async (
|
|
23
|
+
cityName: CityName,
|
|
24
|
+
phoneNumber: string,
|
|
25
|
+
): Promise<Resident[]> => {
|
|
26
|
+
return getResidentsCollection().find({ cityName, phoneNumber }).toArray();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const getResidentByCityAndIdNumber = async (
|
|
30
|
+
cityName: CityName,
|
|
31
|
+
idNumber: string,
|
|
32
|
+
): Promise<Resident | null> => {
|
|
33
|
+
return getResidentsCollection().findOne({ cityName, idNumber });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const getResidentByCityPhoneAndIdNumber = async (
|
|
37
|
+
cityName: CityName,
|
|
38
|
+
phoneNumber: string,
|
|
39
|
+
idNumber: string,
|
|
40
|
+
): Promise<Resident | null> => {
|
|
41
|
+
return getResidentsCollection().findOne({ cityName, phoneNumber, idNumber });
|
|
42
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Document, IndexDescription } from "mongodb";
|
|
2
|
+
|
|
3
|
+
export const residentsJsonSchema: Document = {
|
|
4
|
+
bsonType: "object",
|
|
5
|
+
properties: {
|
|
6
|
+
cityName: { bsonType: "string" },
|
|
7
|
+
firstName: { bsonType: "string" },
|
|
8
|
+
lastName: { bsonType: "string" },
|
|
9
|
+
idNumber: { bsonType: "string" },
|
|
10
|
+
phoneNumber: { bsonType: "string" },
|
|
11
|
+
address: { bsonType: "string" },
|
|
12
|
+
createdAt: { bsonType: "date" },
|
|
13
|
+
updatedAt: { bsonType: "date" },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const residentsIndexes: IndexDescription[] = [
|
|
18
|
+
{ key: { cityName: 1, phoneNumber: 1 }, name: "cityName_1_phoneNumber_1" },
|
|
19
|
+
{
|
|
20
|
+
key: { cityName: 1, idNumber: 1 },
|
|
21
|
+
name: "cityName_1_idNumber_unique",
|
|
22
|
+
unique: true,
|
|
23
|
+
partialFilterExpression: {
|
|
24
|
+
cityName: { $type: "string" },
|
|
25
|
+
idNumber: { $type: "string" },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ObjectId, WithId } from "mongodb";
|
|
2
|
+
|
|
3
|
+
import { CityName } from "../utils/types";
|
|
4
|
+
|
|
5
|
+
export type Resident = {
|
|
6
|
+
_id: ObjectId;
|
|
7
|
+
createdAt?: Date;
|
|
8
|
+
updatedAt?: Date;
|
|
9
|
+
cityName?: CityName;
|
|
10
|
+
firstName?: string;
|
|
11
|
+
lastName?: string;
|
|
12
|
+
idNumber?: string;
|
|
13
|
+
phoneNumber?: string;
|
|
14
|
+
address?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ResidentDoc = WithId<Resident>;
|
|
@@ -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";
|