Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 | 2x 2x 2x 2x 2x 2x 2x 16x 20x 4x 16x 9x 3x 2x 1x 1x 15x 15x 15x 13x 16x 16x 14x 14x 14x 9x 9x 4x 4x 5x 4x 6x 5x 5x 5x 2x 11x 11x 11x 11x 11x 11x 11x 11x 2x 1x 1x 10x 2x 1x 1x 9x 8x 7x 7x 8x 7x 3x 3x 8x 8x 7x 3x 2x 12x 12x 12x 12x 12x 11x 11x 11x 7x 1x 6x 6x 12x 12x 5x 1x 6x 6x 2x 11x 11x 11x 11x 11x 10x 1x 9x 9x 9x 6x 1x 5x 1x 4x 1x 3x 3x 3x 11x 2x 3x 3x 3x 8x | import { and, asc, eq, inArray, isNotNull } from "drizzle-orm";
import { Hono } from "hono";
import type { Dal } from "../../../dal";
import * as schema from "../../../db/schema";
import { RRM_TASK_STATES, RRM_TASK_TYPES } from "../../../db/schema/enums";
import type { RrmTask as RrmTaskRow } from "../../../db/schema/rrm";
import {
ConflictError,
NotFoundError,
ValidationError,
} from "../../../lib/errors";
import { handleError, success } from "../../../lib/response";
import { requireUser } from "../../../lib/utils";
import type { Services } from "../../../services";
const TASKS = schema.rrmTasks;
const PROSPECTS = schema.rrmProspects;
const SUBMISSIONS = schema.rrmSubmissions;
type Env = {
Bindings: CloudflareBindings;
Variables: {
user: { id: string; name: string; email: string } | null;
session: unknown;
dal: Dal;
services: Services;
};
};
const tasks = new Hono<Env>();
/** A task list longer than this is a queue nobody is working, not a page. */
const MAX_TASK_ROWS = 200;
type RrmTaskType = (typeof RRM_TASK_TYPES)[number];
type RrmTaskState = (typeof RRM_TASK_STATES)[number];
/** Mirrors `RrmTask` in apps/portal/src/lib/api/admin/rrm.ts. */
export type RrmTask = {
id: string;
prospectId: string | null;
prospectName: string | null;
type: RrmTaskType;
dueAt: string;
ownerId: string | null;
state: RrmTaskState;
outcomeCode: string | null;
notes: string | null;
evidence: string | null;
};
function isTaskType(value: string): value is RrmTaskType {
return (RRM_TASK_TYPES as readonly string[]).includes(value);
}
function isTaskState(value: string): value is RrmTaskState {
return (RRM_TASK_STATES as readonly string[]).includes(value);
}
function toRrmTask(
row: RrmTaskRow,
prospectName: string | null,
evidence: string | null,
): RrmTask {
return {
id: row.id,
prospectId: row.prospectId,
prospectName,
type: row.type,
dueAt: row.dueAt.toISOString(),
ownerId: row.ownerId,
state: row.state,
outcomeCode: row.outcomeCode,
notes: row.notes,
evidence,
};
}
function requireNonEmpty(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ValidationError(`${field} is required`);
}
return value.trim();
}
function parseOptionalNotes(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== "string") {
throw new ValidationError("notes must be a string");
}
return value;
}
async function findTask(dal: Dal, id: string): Promise<RrmTaskRow> {
const rows = await dal.db
.select()
.from(TASKS)
.where(eq(TASKS.id, id))
.limit(1);
const task = rows[0];
if (!task) throw new NotFoundError("Task", id);
return task;
}
async function prospectNames(
dal: Dal,
ids: string[],
): Promise<Map<string, string | null>> {
const names = new Map<string, string | null>();
if (ids.length === 0) return names;
const rows = await dal.db
.select({ id: PROSPECTS.id, name: PROSPECTS.name })
.from(PROSPECTS)
.where(inArray(PROSPECTS.id, ids));
for (const row of rows) names.set(row.id, row.name);
return names;
}
/**
* The quote a `confirm_intent` task exists to have judged.
*
* `rrm_tasks` has no `evidence` column — the text lives on
* `rrm_submissions.contact_note`, which is what raised the task in the first
* place (see the schema note there). Read newest-last so the most recent
* submission wins; a prospect who submits twice is judged on what they said
* last.
*/
async function contactNotes(
dal: Dal,
ids: string[],
): Promise<Map<string, string>> {
const notes = new Map<string, string>();
if (ids.length === 0) return notes;
const rows = await dal.db
.select({
prospectId: SUBMISSIONS.prospectId,
contactNote: SUBMISSIONS.contactNote,
})
.from(SUBMISSIONS)
.where(
and(
inArray(SUBMISSIONS.prospectId, ids),
isNotNull(SUBMISSIONS.contactNote),
),
)
.orderBy(asc(SUBMISSIONS.dateCreated));
for (const row of rows) {
if (row.contactNote) notes.set(row.prospectId, row.contactNote);
}
return notes;
}
/** Hydrates one already-loaded task row into the wire shape. */
async function hydrate(dal: Dal, task: RrmTaskRow): Promise<RrmTask> {
if (!task.prospectId) return toRrmTask(task, null, null);
const ids = [task.prospectId];
const [names, notes] = await Promise.all([
prospectNames(dal, ids),
task.type === "confirm_intent"
? contactNotes(dal, ids)
: Promise.resolve(new Map<string, string>()),
]);
return toRrmTask(
task,
names.get(task.prospectId) ?? null,
notes.get(task.prospectId) ?? null,
);
}
// GET /tasks?owner&state&type
tasks.get("/tasks", async (c) => {
try {
const dal = c.get("dal");
const owner = c.req.query("owner");
const state = c.req.query("state");
const type = c.req.query("type");
const conditions = [];
if (owner) conditions.push(eq(TASKS.ownerId, owner));
if (state) {
// An unrecognised filter must not quietly widen to "everything" —
// an operator reading a full queue as their own open one would work
// the wrong rows.
if (!isTaskState(state)) {
throw new ValidationError(
`state must be one of: ${RRM_TASK_STATES.join(", ")}`,
);
}
conditions.push(eq(TASKS.state, state));
}
if (type) {
if (!isTaskType(type)) {
throw new ValidationError(
`type must be one of: ${RRM_TASK_TYPES.join(", ")}`,
);
}
conditions.push(eq(TASKS.type, type));
}
const rows = await dal.db
.select()
.from(TASKS)
.where(conditions.length > 0 ? and(...conditions) : undefined)
// Due-first, with `id` breaking ties so a page is stable across two
// tasks created in the same second.
.orderBy(asc(TASKS.dueAt), asc(TASKS.id))
.limit(MAX_TASK_ROWS);
const prospectIds = [
...new Set(
rows
.map((row) => row.prospectId)
.filter((id): id is string => id !== null),
),
];
const confirmIntentIds = [
...new Set(
rows
.filter((row) => row.type === "confirm_intent")
.map((row) => row.prospectId)
.filter((id): id is string => id !== null),
),
];
const [names, notes] = await Promise.all([
prospectNames(dal, prospectIds),
contactNotes(dal, confirmIntentIds),
]);
return success(
c,
rows.map((row) =>
toRrmTask(
row,
row.prospectId ? (names.get(row.prospectId) ?? null) : null,
row.prospectId && row.type === "confirm_intent"
? (notes.get(row.prospectId) ?? null)
: null,
),
),
);
} catch (err) {
return handleError(c, err);
}
});
/**
* POST /tasks/:id/complete
*
* `outcomeCode` is mandatory. For a call task the log is the only record the
* call happened at all, and a task closed with a blank outcome is
* indistinguishable from one nobody ever worked — which is also the record
* TRAI would expect to see against a complaint.
*/
tasks.post("/tasks/:id/complete", async (c) => {
try {
const user = requireUser(c.get("user"));
const dal = c.get("dal");
const id = c.req.param("id");
const payload = await c.req.json<{
outcomeCode?: unknown;
notes?: unknown;
}>();
const outcomeCode = requireNonEmpty(payload.outcomeCode, "outcomeCode");
const notes = parseOptionalNotes(payload.notes);
const task = await findTask(dal, id);
// Re-closing a done task would overwrite the first outcome, and the
// first outcome is the one that was actually observed.
if (task.state !== "open") {
throw new ConflictError(`Task is already ${task.state}`);
}
const now = new Date();
const nextNotes = notes === undefined ? task.notes : notes;
const update = dal.db
.update(TASKS)
.set({ state: "done", outcomeCode, notes: nextNotes, completedAt: now })
.where(eq(TASKS.id, id));
if (task.prospectId) {
// Batched, not sequential: D1 has no transactions, and a closed task
// with no event behind it is a hole in the audit trail.
await dal.db.batch([
update,
dal.rrmEvents.buildEventStatement({
prospectId: task.prospectId,
type: "task_completed",
actorType: "operator",
actorId: user.id,
payload: { taskId: task.id, taskType: task.type, outcomeCode },
occurredAt: now,
}),
]);
} else {
// `prospect_id` is nullable and events require one, so a standalone
// task closes without an event rather than not closing at all.
await update;
}
return success(
c,
await hydrate(dal, {
...task,
state: "done",
outcomeCode,
notes: nextNotes,
completedAt: now,
}),
);
} catch (err) {
return handleError(c, err);
}
});
/**
* POST /tasks/:id/confirm-intent
*
* The ONLY path to `intent_stated`. Three of these decide whether Phase 2 gets
* built, so a submitted contact note raises a task and never sets the stage
* itself — "maybe later" must not be able to count itself. The evidence quote
* is required and lands in the `intent_confirmed` payload, so the number stays
* auditable row by row.
*/
tasks.post("/tasks/:id/confirm-intent", async (c) => {
try {
const user = requireUser(c.get("user"));
const dal = c.get("dal");
const id = c.req.param("id");
const payload = await c.req.json<{
confirmed?: unknown;
evidence?: unknown;
}>();
if (typeof payload.confirmed !== "boolean") {
throw new ValidationError("confirmed must be a boolean");
}
// Required on BOTH branches: a rejection is a judgement about a specific
// quote too, and one recorded without it cannot be reviewed later.
const evidence = requireNonEmpty(payload.evidence, "evidence");
const confirmed = payload.confirmed;
const task = await findTask(dal, id);
if (task.type !== "confirm_intent") {
throw new ValidationError(
`Task ${id} is a ${task.type} task, not confirm_intent`,
);
}
if (task.state !== "open") {
throw new ConflictError(`Task is already ${task.state}`);
}
if (!task.prospectId) {
throw new ConflictError(
"Task has no prospect — intent cannot be attributed",
);
}
const prospectId = task.prospectId;
const now = new Date();
const outcomeCode = confirmed ? "intent_confirmed" : "intent_not_confirmed";
if (confirmed) {
// setStage writes the row and its `stage_changed` event atomically
// (spec §0 rule 7). Never write `stage` directly.
await dal.rrmProspects.setStage(prospectId, "intent_stated", {
actorType: "operator",
actorId: user.id,
reason: "operator confirmed stated intent",
});
}
await dal.db.batch([
dal.db
.update(TASKS)
.set({ state: "done", outcomeCode, completedAt: now })
.where(eq(TASKS.id, id)),
dal.rrmEvents.buildEventStatement({
prospectId,
// The success metric carries its own event type so the count can be
// rebuilt from the log with the quote attached to each row.
type: confirmed ? "intent_confirmed" : "task_completed",
actorType: "operator",
actorId: user.id,
payload: { taskId: task.id, confirmed, evidence },
occurredAt: now,
}),
]);
const names = await prospectNames(dal, [prospectId]);
return success(
c,
toRrmTask(
{ ...task, state: "done", outcomeCode, completedAt: now },
names.get(prospectId) ?? null,
evidence,
),
);
} catch (err) {
return handleError(c, err);
}
});
export default tasks;
|