Skip to content

Commit 6e263b4

Browse files
KevinLourdclaude
andauthored
feat(audiences): POST /audiences persists given Apollo filters verbatim (QE-CHIRO-1102) (#482)
An audience whose filters the caller already holds (titles + location) could only be persisted through the LLM refine loop, so it could never be switched to the QuickEnrich serve source. The new route stores the filters as given (they are the audience's identity on /search/next) and counts them with a free dry-run. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f7a8965 commit 6e263b4

4 files changed

Lines changed: 305 additions & 1 deletion

File tree

‎openapi.json‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5506,6 +5506,103 @@
55065506
"quickenrich"
55075507
]
55085508
},
5509+
"CreateAudienceFromFiltersResponse": {
5510+
"type": "object",
5511+
"properties": {
5512+
"apolloAudienceId": {
5513+
"type": "string",
5514+
"format": "uuid"
5515+
},
5516+
"name": {
5517+
"type": "string"
5518+
},
5519+
"filters": {
5520+
"allOf": [
5521+
{
5522+
"$ref": "#/components/schemas/SearchFilters"
5523+
},
5524+
{
5525+
"description": "Apollo People API Search filters using Apollo-native field names. All filters are combined using AND. Start broad and narrow down to avoid empty results."
5526+
}
5527+
]
5528+
},
5529+
"count": {
5530+
"type": "integer",
5531+
"description": "Live Apollo match-count for the filters, from a free dry-run."
5532+
},
5533+
"serveSource": {
5534+
"type": "string",
5535+
"enum": [
5536+
"apollo",
5537+
"quickenrich"
5538+
]
5539+
},
5540+
"quickenrich": {
5541+
"type": "object",
5542+
"properties": {
5543+
"expressible": {
5544+
"type": "boolean"
5545+
},
5546+
"reasons": {
5547+
"type": "array",
5548+
"items": {
5549+
"type": "string"
5550+
}
5551+
}
5552+
},
5553+
"required": [
5554+
"expressible",
5555+
"reasons"
5556+
]
5557+
}
5558+
},
5559+
"required": [
5560+
"apolloAudienceId",
5561+
"name",
5562+
"filters",
5563+
"count",
5564+
"serveSource",
5565+
"quickenrich"
5566+
]
5567+
},
5568+
"CreateAudienceFromFiltersRequest": {
5569+
"type": "object",
5570+
"properties": {
5571+
"name": {
5572+
"type": "string",
5573+
"minLength": 1,
5574+
"maxLength": 200,
5575+
"description": "Audience name (English).",
5576+
"example": "US Chiropractors (by title)"
5577+
},
5578+
"description": {
5579+
"type": "string",
5580+
"minLength": 1,
5581+
"maxLength": 2000,
5582+
"description": "What the audience is, in one or two sentences (English)."
5583+
},
5584+
"brandId": {
5585+
"type": "string",
5586+
"description": "Brand the audience belongs to."
5587+
},
5588+
"filters": {
5589+
"allOf": [
5590+
{
5591+
"$ref": "#/components/schemas/SearchFilters"
5592+
},
5593+
{
5594+
"description": "Apollo-native People Search filters, stored VERBATIM. A consumer that forwards the same object to /search/next is served from this audience (it is the audience's identity on that route, e.g. for its QuickEnrich serve source). A buying signal goes through POST /audiences/signal."
5595+
}
5596+
]
5597+
}
5598+
},
5599+
"required": [
5600+
"name",
5601+
"description",
5602+
"filters"
5603+
],
5604+
"additionalProperties": false
5605+
},
55095606
"ServeSourceResponse": {
55105607
"type": "object",
55115608
"properties": {
@@ -8513,6 +8610,96 @@
85138610
}
85148611
}
85158612
},
8613+
"/audiences": {
8614+
"post": {
8615+
"summary": "Persist an Apollo audience from given Apollo-native filters",
8616+
"description": "Stores the filters exactly as given (no LLM, no rewrite), counted by a free Apollo dry-run. The returned apolloAudienceId can then be switched to the QuickEnrich serve source (PATCH /audiences/{apolloAudienceId}/serve-source) and bound to a consumer audience whose stored filters are the same object.",
8617+
"parameters": [
8618+
{
8619+
"schema": {
8620+
"type": "string"
8621+
},
8622+
"required": false,
8623+
"name": "x-api-key",
8624+
"in": "header"
8625+
},
8626+
{
8627+
"schema": {
8628+
"type": "string"
8629+
},
8630+
"required": true,
8631+
"name": "x-org-id",
8632+
"in": "header"
8633+
},
8634+
{
8635+
"schema": {
8636+
"type": "string"
8637+
},
8638+
"required": true,
8639+
"name": "x-user-id",
8640+
"in": "header"
8641+
},
8642+
{
8643+
"schema": {
8644+
"type": "string"
8645+
},
8646+
"required": false,
8647+
"name": "x-brand-id",
8648+
"in": "header"
8649+
},
8650+
{
8651+
"schema": {
8652+
"type": "string"
8653+
},
8654+
"required": false,
8655+
"name": "x-run-id",
8656+
"in": "header"
8657+
}
8658+
],
8659+
"requestBody": {
8660+
"required": true,
8661+
"content": {
8662+
"application/json": {
8663+
"schema": {
8664+
"$ref": "#/components/schemas/CreateAudienceFromFiltersRequest"
8665+
}
8666+
}
8667+
}
8668+
},
8669+
"responses": {
8670+
"201": {
8671+
"description": "Persisted",
8672+
"content": {
8673+
"application/json": {
8674+
"schema": {
8675+
"$ref": "#/components/schemas/CreateAudienceFromFiltersResponse"
8676+
}
8677+
}
8678+
}
8679+
},
8680+
"400": {
8681+
"description": "Validation error",
8682+
"content": {
8683+
"application/json": {
8684+
"schema": {
8685+
"$ref": "#/components/schemas/ErrorResponse"
8686+
}
8687+
}
8688+
}
8689+
},
8690+
"500": {
8691+
"description": "Internal server error",
8692+
"content": {
8693+
"application/json": {
8694+
"schema": {
8695+
"$ref": "#/components/schemas/ErrorResponse"
8696+
}
8697+
}
8698+
}
8699+
}
8700+
}
8701+
}
8702+
},
85168703
"/audiences/{apolloAudienceId}/serve-source": {
85178704
"patch": {
85188705
"summary": "Switch an audience between the Apollo and the QuickEnrich serve path",

‎src/routes/audiences.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { toCreditAlertIdentity } from "../lib/credit-alert.js";
3434
import { SuggestFromSegmentRequestSchema, ApolloNativeSearchFiltersSchema, AudienceCompaniesQuerySchema } from "../schemas.js";
3535
import { providerErrorFields } from "../lib/provider-error.js";
3636
import { planQuickenrich } from "../lib/quickenrich.js";
37-
import { ServeSourceRequestSchema, SignalCoverageRequestSchema, CreateSignalAudienceRequestSchema, SearchFiltersSchema, APOLLO_BUYING_SIGNAL_TYPES } from "../schemas.js";
37+
import { ServeSourceRequestSchema, CreateAudienceFromFiltersRequestSchema, SignalCoverageRequestSchema, CreateSignalAudienceRequestSchema, SearchFiltersSchema, APOLLO_BUYING_SIGNAL_TYPES } from "../schemas.js";
3838
import { signalConflicts, signalWindow, utcDay, SignalNotApolloSearchableError, type BuyingSignalSpec, type BuyingSignalType } from "../lib/buying-signal-spec.js";
3939
import { filtersBesideEngagement } from "../lib/linkedin-engagement.js";
4040
import { parseCompetitorPage } from "../lib/linkedin-engagement-spec.js";
@@ -219,6 +219,50 @@ function serveSourceFields(serveSource: string, filters: Record<string, unknown>
219219
};
220220
}
221221

222+
/**
223+
* POST /audiences — persist an audience from Apollo-native filters the caller
224+
* already holds (no refine loop, no LLM). The filters are stored VERBATIM:
225+
* /search/next recognises a QuickEnrich audience by jsonb equality with the
226+
* search params its consumer forwards, so any rewrite here would make the row
227+
* unreachable. Counted by a free dry-run so it is born with its real size.
228+
*/
229+
router.post("/audiences", serviceAuth, async (req: AuthenticatedRequest, res) => {
230+
try {
231+
const parsed = CreateAudienceFromFiltersRequestSchema.safeParse(req.body);
232+
if (!parsed.success) {
233+
return res.status(400).json({ type: "validation", error: "Invalid request", details: parsed.error.flatten() });
234+
}
235+
const filters = req.body.filters as Record<string, unknown>;
236+
const brandId = parsed.data.brandId ?? req.brandIds?.[0] ?? null;
237+
const { key: apolloApiKey } = await decryptKey(
238+
req.orgId!,
239+
req.userId!,
240+
"apollo",
241+
{ callerMethod: "POST", callerPath: "/audiences" },
242+
{ brandIds: brandId ? [brandId] : req.brandIds, featureSlug: req.featureSlug, workflowSlug: req.workflowSlug },
243+
);
244+
const count = await dryRunCount(apolloApiKey, filters, toCreditAlertIdentity(req));
245+
const now = new Date();
246+
const [row] = await db
247+
.insert(apolloAudiences)
248+
.values({
249+
orgId: req.orgId!,
250+
userId: req.userId ?? null,
251+
brandId,
252+
name: parsed.data.name,
253+
description: parsed.data.description,
254+
filters,
255+
count,
256+
countRefreshedAt: now,
257+
})
258+
.returning();
259+
res.status(201).json({ apolloAudienceId: row.id, name: parsed.data.name, filters, count, ...serveSourceFields(row.serveSource ?? "apollo", filters) });
260+
} catch (error) {
261+
console.error("[Apollo Service][POST /audiences] ERROR:", error);
262+
res.status(500).json({ type: "internal", error: error instanceof Error ? errorMessage(error) : "Internal server error", ...providerErrorFields(error) });
263+
}
264+
});
265+
222266
/**
223267
* PATCH /audiences/:apolloAudienceId/serve-source — switch where /search/next
224268
* sources this audience's people. `quickenrich` = free QuickEnrich search +

‎src/schemas.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,30 @@ const AudienceResponseSchema = z
20032003
})
20042004
.openapi("AudienceResponse");
20052005

2006+
export const CreateAudienceFromFiltersRequestSchema = z
2007+
.object({
2008+
name: z.string().min(1).max(200).openapi({ description: "Audience name (English).", example: "US Chiropractors (by title)" }),
2009+
description: z.string().min(1).max(2000).openapi({ description: "What the audience is, in one or two sentences (English)." }),
2010+
brandId: z.string().optional().openapi({ description: "Brand the audience belongs to." }),
2011+
filters: ApolloNativeSearchFiltersSchema.refine((f) => Object.keys(f).length > 0, { message: "filters must state at least one constraint" }).openapi({
2012+
description:
2013+
"Apollo-native People Search filters, stored VERBATIM. A consumer that forwards the same object to /search/next is served from this audience (it is the audience's identity on that route, e.g. for its QuickEnrich serve source). A buying signal goes through POST /audiences/signal.",
2014+
}),
2015+
})
2016+
.strict()
2017+
.openapi("CreateAudienceFromFiltersRequest");
2018+
2019+
const CreateAudienceFromFiltersResponseSchema = z
2020+
.object({
2021+
apolloAudienceId: z.string().uuid(),
2022+
name: z.string(),
2023+
filters: ApolloNativeSearchFiltersSchema,
2024+
count: z.number().int().openapi({ description: "Live Apollo match-count for the filters, from a free dry-run." }),
2025+
serveSource: z.enum(["apollo", "quickenrich"]),
2026+
quickenrich: z.object({ expressible: z.boolean(), reasons: z.array(z.string()) }),
2027+
})
2028+
.openapi("CreateAudienceFromFiltersResponse");
2029+
20062030
export const ServeSourceRequestSchema = z
20072031
.object({ serveSource: z.enum(["apollo", "quickenrich"]) })
20082032
.openapi("ServeSourceRequest");
@@ -2062,6 +2086,23 @@ registry.registerPath({
20622086
},
20632087
});
20642088

2089+
registry.registerPath({
2090+
method: "post",
2091+
path: "/audiences",
2092+
summary: "Persist an Apollo audience from given Apollo-native filters",
2093+
description:
2094+
"Stores the filters exactly as given (no LLM, no rewrite), counted by a free Apollo dry-run. The returned apolloAudienceId can then be switched to the QuickEnrich serve source (PATCH /audiences/{apolloAudienceId}/serve-source) and bound to a consumer audience whose stored filters are the same object.",
2095+
request: {
2096+
headers: audienceHeaders,
2097+
body: { content: { "application/json": { schema: CreateAudienceFromFiltersRequestSchema } }, required: true },
2098+
},
2099+
responses: {
2100+
201: { description: "Persisted", content: { "application/json": { schema: CreateAudienceFromFiltersResponseSchema } } },
2101+
400: { description: "Validation error", content: { "application/json": { schema: ErrorResponseSchema } } },
2102+
500: { description: "Internal server error", content: { "application/json": { schema: ErrorResponseSchema } } },
2103+
},
2104+
});
2105+
20652106
registry.registerPath({
20662107
method: "patch",
20672108
path: "/audiences/{apolloAudienceId}/serve-source",

‎tests/unit/audiences.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,38 @@ describe("Apollo audience endpoints", () => {
188188
app = await createApp();
189189
});
190190

191+
describe("POST /audiences (persist from given filters, QE-CHIRO-1102)", () => {
192+
const TITLES = { person_titles: ["Chiropractor", "Doctor of Chiropractic", "Chiropractic Physician"], person_locations: ["United States"] };
193+
// No LLM on this route: drop the outer queued model turns so they do not
194+
// leak into the next test's queue.
195+
beforeEach(() => {
196+
mockChatComplete.mockReset();
197+
});
198+
199+
it("stores the filters verbatim, counts them, no LLM, and says QuickEnrich can serve them", async () => {
200+
setCounts(5657);
201+
const res = await request(app)
202+
.post("/audiences")
203+
.set(HEADERS)
204+
.send({ name: "US Chiropractors (by title)", description: "Chiropractors in the US", brandId: "brand-1", filters: TITLES })
205+
.expect(201);
206+
expect(res.body.apolloAudienceId).toBe("aud-1");
207+
expect(res.body.count).toBe(5657);
208+
expect(res.body.filters).toEqual(TITLES);
209+
expect(res.body.serveSource).toBe("apollo");
210+
expect(res.body.quickenrich).toEqual({ expressible: true, reasons: [] });
211+
expect(state.insertedRows[0]).toMatchObject({ orgId: "org-1", brandId: "brand-1", filters: TITLES, count: 5657 });
212+
expect(mockChatComplete).not.toHaveBeenCalled();
213+
});
214+
215+
it("refuses empty filters, a non-native key and a buying signal", async () => {
216+
for (const filters of [{}, { personTitles: ["x"] }, { person_titles: ["x"], buying_signal: { type: "hiring", window_days: 30 } }]) {
217+
await request(app).post("/audiences").set(HEADERS).send({ name: "n", description: "d", filters }).expect(400);
218+
}
219+
expect(state.insertedRows).toEqual([]);
220+
});
221+
});
222+
191223
it("POST /suggest-from-segment persists and returns {apolloAudienceId, filters, count}", async () => {
192224
setCounts(1000, 42000);
193225

0 commit comments

Comments
 (0)