Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
52a57ed
Merge pull request #74 from trycompai/main
carhartlewis Aug 7, 2026
1561073
chore: release release
github-actions[bot] Aug 7, 2026
5074fc4
Merge pull request #75 from trycompai/release-please--branches--release
carhartlewis Aug 7, 2026
3c20d80
Merge pull request #77 from trycompai/main
carhartlewis Aug 7, 2026
808b835
Merge pull request #79 from trycompai/main
carhartlewis Aug 7, 2026
407280a
Merge pull request #81 from trycompai/main
carhartlewis Aug 7, 2026
c26a08d
Merge pull request #84 from trycompai/main
carhartlewis Aug 7, 2026
d585dc3
Merge pull request #90 from trycompai/main
carhartlewis Aug 8, 2026
d0299d9
Merge pull request #98 from trycompai/main
carhartlewis Aug 11, 2026
7d4a573
Merge pull request #107 from trycompai/main
carhartlewis Aug 11, 2026
56f4eeb
Merge pull request #116 from trycompai/main
github-actions[bot] Aug 11, 2026
57001e6
Merge pull request #119 from trycompai/main
github-actions[bot] Aug 11, 2026
ad1d702
Merge pull request #122 from trycompai/main
github-actions[bot] Aug 11, 2026
fc0c594
Merge pull request #127 from trycompai/main
github-actions[bot] Aug 11, 2026
4ffe150
Merge pull request #130 from trycompai/main
github-actions[bot] Aug 11, 2026
14cd220
Merge pull request #135 from trycompai/main
github-actions[bot] Aug 11, 2026
42e41dc
feat(agent): surface optional capabilities and full-agentic checklist
aiandwebservices-cyber Aug 12, 2026
a91b622
fix(agent): keep keyless brand PENDING on the priority-900 direct lane
aiandwebservices-cyber Aug 12, 2026
5b9f96e
test(calendar): lock calendar sync substrate and hand off Gmail work
aiandwebservices-cyber Aug 12, 2026
3713dc2
fix(agent): harden identity matching with Context slug search
aiandwebservices-cyber Aug 12, 2026
ae77da2
feat(agent): pin portrait lane soft-fail and 30-day stand-down
aiandwebservices-cyber Aug 12, 2026
2f3861b
feat: detect stalled deals and raise owner tasks without an LLM
aiandwebservices-cyber Aug 12, 2026
18ae5e6
feat(agent): job-change detection with owner task and gated re-parent
aiandwebservices-cyber Aug 12, 2026
a0481cd
feat(agent): meeting-prep briefs refuse untrusted identity
aiandwebservices-cyber Aug 12, 2026
a729d01
test(gmail): lock thread timeline projection and hand off auto-create
aiandwebservices-cyber Aug 12, 2026
cf70a19
feat(agent): Deploy-gated Qualify specialist with lifecycleRole
aiandwebservices-cyber Aug 12, 2026
a832e2e
feat(agent): Deploy-gated Qualify specialist with lifecycleRole
aiandwebservices-cyber Aug 12, 2026
211d397
feat(agent): Deploy-gated Close specialist (recommend-only)
aiandwebservices-cyber Aug 12, 2026
f7de7b9
feat(agent): Deploy-gated Engage specialist (recommend-only)
aiandwebservices-cyber Aug 12, 2026
7c60192
feat(agent): Deploy-gated Advance specialist with lifecycleRole
aiandwebservices-cyber Aug 12, 2026
1ec13f5
test(mailbox): lock two-way auto-create with provenance tags
aiandwebservices-cyber Aug 12, 2026
9dc2637
feat(agent): team-agent observability for runs, quality, and cost
aiandwebservices-cyber Aug 12, 2026
f2aa1f1
feat: deal score and forecast context on Phase 11 path
aiandwebservices-cyber Aug 12, 2026
0e4ba64
merge fm/crm-brand-priority into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
64c773b
merge fm/crm-portrait-priority into agentic wave (resolve lanes test)
aiandwebservices-cyber Aug 12, 2026
91e32d5
merge fm/crm-identity-matching into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
7ee58ea
merge fm/crm-calendar-sync into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
412aac7
merge fm/crm-gmail-threads into agentic wave (combine plan status)
aiandwebservices-cyber Aug 12, 2026
a0709ae
merge fm/crm-auto-create into agentic wave (combine plan status)
aiandwebservices-cyber Aug 12, 2026
74e02d5
merge fm/crm-stalled-deal into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
591bb73
merge fm/crm-job-change into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
b5996a7
merge fm/crm-meeting-prep into agentic wave (keep job-change + meetin…
aiandwebservices-cyber Aug 12, 2026
1fe028f
merge fm/crm-agent-qualify into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
31646f5
merge fm/crm-agent-engage into agentic wave (captain-approved land)
aiandwebservices-cyber Aug 12, 2026
86d5160
merge fm/crm-agent-advance into agentic wave (keep qualify+engage+adv…
aiandwebservices-cyber Aug 12, 2026
1eefad6
merge fm/crm-agent-close into agentic wave (keep all four lifecycle r…
aiandwebservices-cyber Aug 12, 2026
8505823
merge fm/crm-agent-observability into agentic wave (captain-approved …
aiandwebservices-cyber Aug 12, 2026
114041b
merge fm/crm-deal-score-forecast into agentic wave (keep stalled-deal…
aiandwebservices-cyber Aug 12, 2026
ef0cb4f
fix(api): optional-chain occurredAt in calendar-sync typecheck
aiandwebservices-cyber Aug 12, 2026
36f8251
style: biome format for agentic wave files
aiandwebservices-cyber Aug 12, 2026
ae84334
style: format team-agents-index cost stat
aiandwebservices-cyber Aug 12, 2026
de9ad1a
fix(telemetry): allowlist write_deal_intelligence tool
aiandwebservices-cyber Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ GOOGLE_CLIENT_SECRET=""
# already knows (your own email and calendar history) and simply reports what
# it could not check. Each key you add unlocks one more place it can look, and
# it tells you at startup which ones are on.
#
# Full agentic mode checklist (env names only; Context is Settings → General):
# RAPIDAPI_KEY
# PERPLEXITY_API_KEY
# BLOB_READ_WRITE_TOKEN
# AGENT_BRIDGE_SECRET (same value for app + agent; see block above)
# Context key (Settings → General — not a variable)

# Perplexity — finds where a person lives on the web, and recent news worth
# knowing before a call. https://perplexity.ai/settings/api
Expand Down
2 changes: 2 additions & 0 deletions apps/agent/agent/lib/agent-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CRM_EVENT_TYPES } from "@crm/db/crm-events";
import { z } from "zod";
import { AGENT_ACTION_TYPES } from "./agent-actions";
import { LIFECYCLE_ROLES } from "./lifecycle-roles";

const slackDestination = z.object({
kind: z.enum(["channel", "user"]),
Expand Down Expand Up @@ -65,6 +66,7 @@ export const agentManifestResource = z.object({
export const agentManifest = z
.object({
description: z.string().optional(),
lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(),
actions: z.array(agentManifestAction).min(1),
triggers: z.array(agentManifestTrigger).min(1),
dataScope: z.object({
Expand Down
17 changes: 17 additions & 0 deletions apps/agent/agent/lib/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,20 @@ export function sensitiveWrite(instead: string): Approval {
}
: "user-approval";
}

export function sensitiveWhen<TInput>(
needsApproval: (input: TInput | undefined) => boolean,
instead: string,
): Approval<TInput> {
return ({ session, toolInput }) => {
if (!needsApproval(toolInput as TInput | undefined)) {
return "not-applicable";
}
return isAutomated(session)
? {
type: "denied" as const,
reason: `Not something to do unattended. ${instead}`,
}
: "user-approval";
};
}
8 changes: 4 additions & 4 deletions apps/agent/agent/lib/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ export async function runBrand({
if (!company) return { enriched: false, reason: "No such company." };

if (!(await contextDevEnabled())) {
const reason =
"Context.dev is not configured, so there is nowhere to look.";
await settle(companyId, EnrichmentStatus.SKIPPED, reason);
return { enriched: false, reason };
return {
enriched: false,
reason: "Context.dev is not configured, so there is nowhere to look.",
};
}

if (!company.domain) {
Expand Down
60 changes: 60 additions & 0 deletions apps/agent/agent/lib/brief-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FactBand } from "@crm/db";
import { type Evidence, type EvidenceKind, scoreEvidence } from "./evidence";
import { isDerivedName } from "./names";

export const IDENTITY_PROOF_KINDS = [
"profile.email-match",
"linkedin.employer-and-name",
"crm.thread-reply",
"crm.signature-block",
"github.account-identity",
] as const satisfies readonly EvidenceKind[];

const identityKinds = new Set<string>(IDENTITY_PROOF_KINDS);

export type ContactIdentitySnapshot = {
email: string | null;
firstName: string;
lastName: string | null;
linkedinUrl: string | null;
hasAppliedName: boolean;
};

export function evidenceProvesIdentity(evidence: Evidence[]): boolean {
return evidence.some((item) => identityKinds.has(item.kind));
}

export function contactIdentityIsTrustworthy(
contact: ContactIdentitySnapshot,
): boolean {
if (contact.linkedinUrl) return true;
if (contact.hasAppliedName) return true;

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A weak, merely applied name can bypass the brief's identity gate. The applied-name signal should carry a VERIFIED/identity-proof requirement (or its evidence band) instead of accepting every APPLIED name, otherwise briefs can be written for contacts whose identity was only supported by POSSIBLE evidence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/brief-identity.ts, line 31:

<comment>A weak, merely applied name can bypass the brief's identity gate. The applied-name signal should carry a VERIFIED/identity-proof requirement (or its evidence band) instead of accepting every APPLIED name, otherwise briefs can be written for contacts whose identity was only supported by POSSIBLE evidence.</comment>

<file context>
@@ -0,0 +1,60 @@
+	contact: ContactIdentitySnapshot,
+): boolean {
+	if (contact.linkedinUrl) return true;
+	if (contact.hasAppliedName) return true;
+	if (
+		contact.lastName &&
</file context>
Fix with cubic

if (
contact.lastName &&
!isDerivedName(contact.email, contact.firstName, contact.lastName)

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In contactIdentityIsTrustworthy, the last-name branch intends to reject a name still derived from the email handle, but isDerivedName returns false whenever lastName is non-null — exactly the branch this call lives in — so !isDerivedName(...) is always true and only contact.lastName is actually being tested. Any contact with a stored last name, including one split from the email local part (e.g. paula.marchetti@… → Paula/Marchetti via splitName), is treated as trustworthy with no LinkedIn and no applied-name fact, weakening the meeting-prep identity gate. Consider making isDerivedName detect full names derived from both local-part tokens, or gate on the placeholder case explicitly rather than on the mere presence of a last name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/brief-identity.ts, line 34:

<comment>In `contactIdentityIsTrustworthy`, the last-name branch intends to reject a name still derived from the email handle, but `isDerivedName` returns false whenever `lastName` is non-null — exactly the branch this call lives in — so `!isDerivedName(...)` is always true and only `contact.lastName` is actually being tested. Any contact with a stored last name, including one split from the email local part (e.g. `paula.marchetti@…` → Paula/Marchetti via `splitName`), is treated as trustworthy with no LinkedIn and no applied-name fact, weakening the meeting-prep identity gate. Consider making `isDerivedName` detect full names derived from both local-part tokens, or gate on the placeholder case explicitly rather than on the mere presence of a last name.</comment>

<file context>
@@ -0,0 +1,60 @@
+	if (contact.hasAppliedName) return true;
+	if (
+		contact.lastName &&
+		!isDerivedName(contact.email, contact.firstName, contact.lastName)
+	) {
+		return true;
</file context>
Fix with cubic

) {
return true;
}
return false;
}

export function refuseBriefReason(input: {
contact: ContactIdentitySnapshot;
evidence: Evidence[];
}): string | null {
const identityOk =
contactIdentityIsTrustworthy(input.contact) ||
evidenceProvesIdentity(input.evidence);

if (!identityOk) {
return "Identity is not trustworthy yet. Identify them first, then write the brief.";
}

const scored = scoreEvidence(input.evidence);

if (scored.band === null || scored.band === FactBand.POSSIBLE) {
return "Nothing here is sourced well enough to put on the record.";
}

return null;
}
3 changes: 3 additions & 0 deletions apps/agent/agent/lib/builder-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { readAgentModel } from "@crm/db/settings";
import { WORKSPACE_ID } from "@crm/db/workspace";
import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions";
import type { LifecycleRole } from "./lifecycle-roles";
import { requestStaleSlackInventorySync } from "./slack-people";

const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
Expand Down Expand Up @@ -56,6 +57,7 @@ export type DraftAgentInput = {
name: string;
description: string;
instructions: string;
lifecycleRole?: LifecycleRole;
triggers: DraftTrigger[];
recordScope: "SELECTED" | "WORKSPACE";
resources: BuilderResource[];
Expand Down Expand Up @@ -232,6 +234,7 @@ export async function saveBuilderDraft(
kind: "crm-team-agent",
name: input.name,
description: input.description,
...(input.lifecycleRole ? { lifecycleRole: input.lifecycleRole } : {}),
triggers: manifestTriggers,
dataScope: {
mode: input.recordScope,
Expand Down
81 changes: 79 additions & 2 deletions apps/agent/agent/lib/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,50 @@ export type Capability = {
readonly from: string;
};

export type EnableChecklistItem = {
readonly id: string;
readonly label: string;
readonly source: string;
readonly kind: "env" | "setting";
};

export const FULL_AGENTIC_CHECKLIST: readonly EnableChecklistItem[] = [

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: FULL_AGENTIC_CHECKLIST re-declares (id + label + source) the same four env capabilities that capabilitiesFrom() constructs inline, and docs/environment.md explicitly calls this list the 'source of truth'. The runtime capability set actually lives in capabilitiesFrom(), so the two lists can silently drift: adding a new env capability there without also updating the checklist leaves the enable checklist stale even though FULL_AGENTIC_ENV_VARS is asserted to the exact four known vars in the test. Worth deriving the env portion from a single list, or adding a test that fails when capabilitiesFrom() and FULL_AGENTIC_CHECKLIST disagree, so a future capability can't be missed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/capabilities.ts, line 23:

<comment>FULL_AGENTIC_CHECKLIST re-declares (id + label + source) the same four env capabilities that capabilitiesFrom() constructs inline, and docs/environment.md explicitly calls this list the 'source of truth'. The runtime capability set actually lives in capabilitiesFrom(), so the two lists can silently drift: adding a new env capability there without also updating the checklist leaves the enable checklist stale even though `FULL_AGENTIC_ENV_VARS` is asserted to the exact four known vars in the test. Worth deriving the env portion from a single list, or adding a test that fails when capabilitiesFrom() and FULL_AGENTIC_CHECKLIST disagree, so a future capability can't be missed.</comment>

<file context>
@@ -13,6 +13,50 @@ export type Capability = {
+	readonly kind: "env" | "setting";
+};
+
+export const FULL_AGENTIC_CHECKLIST: readonly EnableChecklistItem[] = [
+	{
+		id: "RAPIDAPI_KEY",
</file context>
Fix with cubic

{
id: "RAPIDAPI_KEY",
label: "LinkedIn",
source: "RAPIDAPI_KEY",
kind: "env",
},
{
id: "PERPLEXITY_API_KEY",
label: "Web research",
source: "PERPLEXITY_API_KEY",
kind: "env",
},
{
id: CONTEXT_DEV,
label: "Company brand data",
source: "Settings → General",
kind: "setting",
},
{
id: "BLOB_READ_WRITE_TOKEN",
label: "Picture storage",
source: "BLOB_READ_WRITE_TOKEN",
kind: "env",
},
{
id: "AGENT_BRIDGE_SECRET",
label: "Agent panel",
source: "AGENT_BRIDGE_SECRET",
kind: "env",
},
] as const;

export const FULL_AGENTIC_ENV_VARS = FULL_AGENTIC_CHECKLIST.filter(
(item) => item.kind === "env",
).map((item) => item.source);

export async function contextDevKey(): Promise<string | null> {
try {
return await readContextDevKey(db);
Expand Down Expand Up @@ -51,13 +95,14 @@ export function capabilitiesFrom(
...fromEnv("PERPLEXITY_API_KEY"),
label: "Web research",
gives:
"open-web context with citations, and the search that finds a LinkedIn slug in the first place",
"open-web context with citations for research, not for identity matching",
},
{
id: CONTEXT_DEV,
from: "Settings → General",
label: "Company brand data",
gives: "a company's logo, industry, location and socials from its domain",
gives:
"a company's logo, industry, location and socials from its domain, and Context web search that finds LinkedIn candidate slugs for identity matching",
enabled: contextDev !== null,
},
{
Expand All @@ -66,6 +111,12 @@ export function capabilitiesFrom(
gives:
"somewhere to keep a logo or a profile photo. Without it a record has no picture at all, because the URLs these sources hand back expire and are never stored as they are",
},
{
...fromEnv("AGENT_BRIDGE_SECRET"),
label: "Agent panel",
gives:
"a rep can open a contact, company or deal Agent tab and talk to you live, and the API can poke dispatch without waiting for the schedule",
},
];
}

Expand Down Expand Up @@ -137,3 +188,29 @@ export function markdownFor(all: readonly Capability[]): string {

return lines.join("\n");
}

export function enableChecklistMarkdown(
all: readonly Capability[] = capabilitiesFrom(null),

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The no-argument checklist always shows the stored Context key as missing, so a configured install can be told it is not fully enabled. Requiring resolved capabilities here (or making the helper async and loading capabilities()) would prevent a false checklist result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/capabilities.ts, line 193:

<comment>The no-argument checklist always shows the stored Context key as missing, so a configured install can be told it is not fully enabled. Requiring resolved capabilities here (or making the helper async and loading `capabilities()`) would prevent a false checklist result.</comment>

<file context>
@@ -137,3 +188,29 @@ export function markdownFor(all: readonly Capability[]): string {
 }
+
+export function enableChecklistMarkdown(
+	all: readonly Capability[] = capabilitiesFrom(null),
+): string {
+	const byId = new Map(all.map((capability) => [capability.id, capability]));
</file context>
Fix with cubic

): string {
const byId = new Map(all.map((capability) => [capability.id, capability]));
const lines = [
"## Full agentic mode enable checklist",
"",
"Env vars only (names, never values). Same value for `AGENT_BRIDGE_SECRET` on the app and the agent.",
"",
];

for (const item of FULL_AGENTIC_CHECKLIST) {
if (item.kind === "env") {
const on = byId.get(item.id)?.enabled === true;
lines.push(`- [${on ? "x" : " "}] \`${item.source}\` — ${item.label}`);
} else {
const on = byId.get(item.id)?.enabled === true;
lines.push(
`- [${on ? "x" : " "}] Context key at \`${item.source}\` — ${item.label} (not an env var)`,
);
}
}

return lines.join("\n");
}
31 changes: 31 additions & 0 deletions apps/agent/agent/lib/crm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,34 @@ export async function writeTimelineNote(

return activity.id;
}

export async function writeOwnerTask(
contactId: string,
subject: string,
body: string,
dueAt: Date,
meta: Record<string, unknown> = {},
): Promise<string | null> {
const contact = await db.contact.findUnique({
where: { id: contactId },
select: { companyId: true, ownerId: true },
});
if (!contact?.ownerId) return null;

const activity = await db.activity.create({
data: {
type: "TASK",
subject,
body,
occurredAt: new Date(),
dueAt,
contactId,
companyId: contact.companyId,
createdById: contact.ownerId,
meta: { ...meta, agent: "people-research" },
},
select: { id: true },
});

return activity.id;
}
90 changes: 90 additions & 0 deletions apps/agent/agent/lib/deal-intelligence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { db } from "@crm/db";
import {
blankToNull,
clampDealScore,
DEAL_SCORE,
isValidDealScore,
} from "@crm/db/deal-score";

export type WriteDealIntelligenceInput = {
dealId: string;
score: number;
summary: string;
forecastContext: string;
};

export type WriteDealIntelligenceResult =
| {
written: true;
score: number;
scoredAt: string;
}
| {
written: false;
reason: string;
};

export async function writeDealIntelligence(
input: WriteDealIntelligenceInput,
): Promise<WriteDealIntelligenceResult> {
const score = clampDealScore(input.score);

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Invalid scores are silently rewritten before validation, so a malformed score can become a persisted 0, 100, or rounded value and produce misleading deal intelligence. Validate input.score before clamping (or remove clamping and reject invalid values).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/deal-intelligence.ts, line 30:

<comment>Invalid scores are silently rewritten before validation, so a malformed score can become a persisted 0, 100, or rounded value and produce misleading deal intelligence. Validate `input.score` before clamping (or remove clamping and reject invalid values).</comment>

<file context>
@@ -0,0 +1,90 @@
+export async function writeDealIntelligence(
+	input: WriteDealIntelligenceInput,
+): Promise<WriteDealIntelligenceResult> {
+	const score = clampDealScore(input.score);
+	if (!isValidDealScore(score)) {
+		return {
</file context>
Fix with cubic

if (!isValidDealScore(score)) {
return {
written: false,
reason: `Score must be an integer from ${DEAL_SCORE.min} to ${DEAL_SCORE.max}.`,
};
}

const summary = blankToNull(input.summary);
if (!summary) {
return {
written: false,
reason: "Score summary is required.",
};
}
if (summary.length > DEAL_SCORE.summaryMax) {
return {
written: false,
reason: `Score summary must be at most ${DEAL_SCORE.summaryMax} characters.`,
};
}

const forecastContext = blankToNull(input.forecastContext);
if (!forecastContext) {
return {
written: false,
reason: "Forecast context is required.",
};
}
if (forecastContext.length > DEAL_SCORE.forecastMax) {
return {
written: false,
reason: `Forecast context must be at most ${DEAL_SCORE.forecastMax} characters.`,
};
}

const deal = await db.deal.findUnique({
where: { id: input.dealId },
select: { id: true },
});
if (!deal) {
return { written: false, reason: "No such deal." };
}

const scoredAt = new Date();
await db.deal.update({
where: { id: input.dealId },
data: {
dealScore: score,
dealScoreSummary: summary,
dealScoredAt: scoredAt,
forecastContext,
},
});

return {
written: true,
score,
scoredAt: scoredAt.toISOString(),
};
}
Loading
Loading