Implement campaigns module with support for the Campaigns API#257
Conversation
Mudaafi
left a comment
There was a problem hiding this comment.
This is looking pretty good! I just have some comments w.r.t. the types, but thanks for working on adding this in!
| export interface CampaignRuleInput extends Record<string, any> { | ||
| rule: Record<string, any>; | ||
| rule_type?: | ||
| | 'boost' | ||
| | 'blacklist' | ||
| | 'slot' | ||
| | 'content' | ||
| | 'filters_slot' | ||
| | 'whitelist' | ||
| | 'variation_slicing'; | ||
| request_tag_name?: RequestTag; | ||
| request_tag_value?: string; | ||
| active?: boolean; | ||
| start_time?: string; | ||
| end_time?: string; | ||
| campaign_id?: number; | ||
| automatically_generated?: boolean; | ||
| } |
There was a problem hiding this comment.
Seems a little verbose to repeat type declarations here. Can we use something like this instead? wdyt?
| export interface CampaignRuleInput extends Record<string, any> { | |
| rule: Record<string, any>; | |
| rule_type?: | |
| | 'boost' | |
| | 'blacklist' | |
| | 'slot' | |
| | 'content' | |
| | 'filters_slot' | |
| | 'whitelist' | |
| | 'variation_slicing'; | |
| request_tag_name?: RequestTag; | |
| request_tag_value?: string; | |
| active?: boolean; | |
| start_time?: string; | |
| end_time?: string; | |
| campaign_id?: number; | |
| automatically_generated?: boolean; | |
| } | |
| export interface CampaignRuleInput extends Partial<Omit<CampaignRule, 'created_at' | 'updated_at'>> { | |
| id: number; | |
| } |
There was a problem hiding this comment.
Done, with two tweaks: omitted id instead of requiring it (it's server-assigned on create), and used a distributive OmitServerFields<T> helper so the discriminated union doesn't collapse (a plain Omit would decouple rule_type from rule). Fixed in b3d1005.
| requestTagName?: RequestTag; | ||
| requestTagValue?: string; | ||
| startTime?: string; | ||
| endTime?: string; | ||
| refinedQueries?: RefinedQuery[]; | ||
| refinedFilters?: RefinedFilter[]; | ||
| refinedRecommendationContexts?: RecommendationContext[]; | ||
| boostRules?: CampaignRuleInput[]; | ||
| blacklistRules?: CampaignRuleInput[]; | ||
| slotRules?: CampaignRuleInput[]; | ||
| contentRules?: CampaignRuleInput[]; | ||
| filtersSlotRules?: CampaignRuleInput[]; | ||
| whitelistRule?: CampaignRuleInput | null; | ||
| variationSlicingRule?: CampaignRuleInput | null; | ||
| metadataJson?: CampaignMetadata; |
There was a problem hiding this comment.
Let's make this a CampaignRule object since it's being re-used across multiple interfaces.
There was a problem hiding this comment.
or actually we already have a Campaign object below. Can't we reuse that here rather than redefining all these fields?
There was a problem hiding this comment.
Extracted a shared CampaignParametersBase that both Create/Update extend. Didn't reuse Campaign since it's the snake_case response shape (rules carry server-assigned ids), whereas these are camelCase input params. Fixed in edc257e.
|
|
||
| export interface RecommendationContext extends Record<string, any> { | ||
| pod_id: string; | ||
| condition: Record<string, any>; |
There was a problem hiding this comment.
This can be defined into three types of conditions right? https://docs.constructor.com/reference/v1-searchandising-create-campaign
| condition: Record<string, any>; | ||
| } | ||
|
|
||
| export interface CampaignRule extends Record<string, any> { |
There was a problem hiding this comment.
Let's generalize this to RuleObj since it's a shared object for searchandizing rules as well
|
|
||
| export interface CampaignRule extends Record<string, any> { | ||
| id: number; | ||
| rule: Record<string, any>; |
There was a problem hiding this comment.
Similarly, we can define a generic rule object and branch it out to the different types by following the schema here: https://github.com/Constructor-io/autocomplete/blob/master/ingestion_service/src/ingestion_service/blueprints/serializers/rules.py#L162
| * @param {object} [parameters.refinedFilters] - An object of refined filters to filter by | ||
| * @param {number} [parameters.numResultsPerPage=20] - The number of campaigns to return - maximum value 100 | ||
| * @param {number} [parameters.page] - The page of results to return | ||
| * @param {number} [parameters.offset] - The number of results to skip from the beginning - cannot be used together with `page` | ||
| * @param {object} [parameters.refinedRecommendationContexts] - A filter for refined recommendation contexts | ||
| * @param {string[]} [parameters.refinedQueries] - A list of refined queries to filter by |
There was a problem hiding this comment.
I know this is the order the documentation lists the parameters, but let's move the refinedQueries and refinedRecommendationContexts` up just for better QOL
| * @param {object} [parameters.refinedFilters] - An object of refined filters to filter by | |
| * @param {number} [parameters.numResultsPerPage=20] - The number of campaigns to return - maximum value 100 | |
| * @param {number} [parameters.page] - The page of results to return | |
| * @param {number} [parameters.offset] - The number of results to skip from the beginning - cannot be used together with `page` | |
| * @param {object} [parameters.refinedRecommendationContexts] - A filter for refined recommendation contexts | |
| * @param {string[]} [parameters.refinedQueries] - A list of refined queries to filter by | |
| * @param {string[]} [parameters.refinedQueries] - A list of refined queries to filter by | |
| * @param {object} [parameters.refinedFilters] - An object of refined filters to filter by | |
| * @param {object} [parameters.refinedRecommendationContexts] - A filter for refined recommendation contexts | |
| * @param {number} [parameters.numResultsPerPage=20] - The number of campaigns to return - maximum value 100 | |
| * @param {number} [parameters.page] - The page of results to return | |
| * @param {number} [parameters.offset] - The number of results to skip from the beginning - cannot be used together with `page` | |
There was a problem hiding this comment.
I'll see if I can throw up a PR to the official docs to better improve the docs there too
| it('Should retrieve a list of campaigns given section', (done) => { | ||
| const section = 'Products'; |
There was a problem hiding this comment.
If we want to include this test, we should use a different section. Since the default is always Products, we won't know if this actually succeeds or if it's just using the default.
Note: This means upon starting the test-suite, we'll probably want to make sure a campaign exists in another section as well.
TarekAlQaddy
left a comment
There was a problem hiding this comment.
left 2 small comments.
What do you think about having a separate sub-module for campaigns instead of having it inside searchandising.js? I don't like that catalog.js has become a huge file now (~5k lines) that includes different entities.
I'm thinking about having
searchandising/index.js
searchandising/campaigns.js
searchandising/search.js
...
each sub-module would be a separate class having it's own methods and it will be initiated in the parent constructor. From the consumer's POV it should be used as such searchandising.campaigns.retrieveCampiagns()
Please let me know your thoughts 👀
| refinedQueries = refined_queries, | ||
| } = parameters; | ||
|
|
||
| // Pull section from parameters |
There was a problem hiding this comment.
I was wondering if these type of comments (Pull X from parameters) add value as they are used for every query param in this method which might be excessive
There was a problem hiding this comment.
Agreed — removed the repetitive // Pull X from parameters comments in 20379dd.
| setTimeout(done, sendTimeout); | ||
| }); | ||
|
|
||
| describe('retrieveCampaigns', () => { |
There was a problem hiding this comment.
I would suggest adding tests to validate passing the query params in "retrieveCampaigns" method as they are explicitly extracted from the "parameters" param
There was a problem hiding this comment.
Added query-param tests for retrieveCampaigns in b481ff9, covering each param extracted from parameters — id (single + array), refinedFilters, refinedRecommendationContexts, refinedQueries, plus their snake_case aliases. Also added the missing searchandising.test-d.ts type test.
Split the flat searchandising module into a parent class that exposes a campaigns sub-module, so each searchandising entity lives in its own class and file. Consumers now call searchandising.campaigns.retrieveCampaigns(). Also drops the repetitive "Pull X from parameters" comments.
Cover query params extracted from parameters in retrieveCampaigns (id, refinedFilters, refinedRecommendationContexts, refinedQueries, plus their snake_case aliases) and add the missing searchandising type tests.
Use an interface for the campaigns sub-module so the type file keeps a single declared class, satisfying the max-classes-per-file lint rule that also runs over .d.ts files in the pre-push hook.
|
@TarekAlQaddy good call on the sub-module split — done in 20379dd. Restructured into: Usage is now |
There was a problem hiding this comment.
Code Review
This PR implements a new searchandising module with a campaigns sub-module exposing CRUD operations for the Campaigns API, complete with TypeScript type definitions and integration tests — the implementation is well-structured and consistent with existing modules, but has a few bugs and quality issues that should be addressed before merging.
Inline comments: 9 discussions added
Overall Assessment:
| @@ -0,0 +1,428 @@ | |||
| /* eslint-disable max-len */ | |||
| /* eslint-disable camelcase, no-underscore-dangle, no-unneeded-ternary, brace-style */ | |||
There was a problem hiding this comment.
Important Issue: no-underscore-dangle and no-unneeded-ternary appear in the blanket disable comment but neither pattern is actually present in this file. brace-style violations are also not visible. Remove the rules that aren't needed so future readers don't have to wonder why they were disabled.
Additionally, the max-len disable on line 1 is split across two separate eslint-disable comments, which is unconventional — they can be merged into one.
| serviceUrl, | ||
| version, | ||
| } = options; | ||
| let queryParams = { |
There was a problem hiding this comment.
Important Issue: The c (client version) parameter is placed in queryParams before additionalQueryParams is spread onto it:
let queryParams = {
c: version,
...additionalQueryParams,
};If additionalQueryParams ever contains a key c, it will silently override the version. Compare with createCatalogUrl in catalog.js, which spreads additionalQueryParams first and then sets c (and later key) unconditionally after the spread so that they cannot be overridden. Apply the same ordering here for safety.
| queryParams.section = section; | ||
| } | ||
|
|
||
| if (id) { |
There was a problem hiding this comment.
Important Issue: Using a truthy check (if (id)) to decide whether to forward the id parameter will silently drop id: 0. While campaign IDs are unlikely to be 0 in practice, numeric 0 is falsy in JavaScript and would be excluded. The same pattern is repeated for page, offset, and numResultsPerPage. Use an explicit null/undefined check instead:
if (id != null) {
queryParams.id = id;
}The same applies to page, offset, and numResultsPerPage.
| * section: 'Products', | ||
| * }); | ||
| */ | ||
| retrieveCampaign(parameters = {}, networkParameters = {}) { |
There was a problem hiding this comment.
Important Issue: retrieveCampaign does not validate that id is provided before using it in the URL path (campaigns/${id}). When id is undefined, the constructed URL becomes campaigns/undefined, which will result in a confusing 404 from the server rather than a clear client-side validation error. Add an explicit check and return a rejected promise (matching the pattern used for createCampaignsUrl path validation):
if (id == null) {
return Promise.reject(new Error('id is a required parameter'));
}The same issue applies to updateCampaign and deleteCampaign.
| } | ||
|
|
||
| return helpers.throwHttpErrorFromResponse(new Error(), response); | ||
| }).then((json) => json); |
There was a problem hiding this comment.
Suggestion: The final .then((json) => json) is a no-op — it returns the same value it receives and serves no purpose. This pattern is repeated across retrieveCampaigns, retrieveCampaign, createCampaign, and updateCampaign. Remove the redundant .then calls to reduce noise.
| numResultsPerPage?: number; | ||
| page?: number; | ||
| offset?: number; | ||
| refinedRecommendationContexts?: RefinedRecommendationContext; |
There was a problem hiding this comment.
Important Issue: In RetrieveCampaignsParameters, refinedRecommendationContexts is typed as a single RefinedRecommendationContext object (with optional pod_id and condition_type arrays). However, in CampaignParametersBase (used for create/update), the same field is RecommendationContext[] — an array of objects each with pod_id and a condition discriminated union.
These two shapes are inconsistent with each other and with the JSDoc in campaigns.js, which describes the retrieve-side parameter as {object} (implying a filter map). Confirm the correct API contract and align the type with the actual parameter the backend accepts.
| goal_description?: string; | ||
| } | ||
|
|
||
| export interface Campaign extends Record<string, any> { |
There was a problem hiding this comment.
Suggestion: Extending Record<string, any> on Campaign effectively widens the type to any for all property accesses not explicitly declared, defeating the purpose of the declared properties. If the intent is to allow additional undocumented server fields, consider using an index signature scoped to unknown values ([key: string]: unknown) or simply drop the extension if consumers should not rely on undeclared fields.
|
|
||
| return fetch(requestUrl, { | ||
| method: 'POST', | ||
| body: JSON.stringify(helpers.toSnakeCaseKeys(body, false)), |
There was a problem hiding this comment.
Important Issue: toSnakeCaseKeys is called with toRecurse = false, meaning only the top-level keys of the request body are converted to snake_case. Nested camelCase keys inside objects like boostRules, slotRules, etc. will be sent as-is to the API. The JSDoc examples show camelCase input (e.g. boostRules). Verify whether the API expects fully snake_cased nested bodies; if so, pass true for toRecurse, or document clearly that callers must pass nested keys in snake_case already.
| } | ||
| }); | ||
|
|
||
| describe('createCampaign', () => { |
There was a problem hiding this comment.
Suggestion: createCampaignName is declared with const createCampaignName = \test-campaign-${Date.now()}`at thedescribeblock level.Date.now()is evaluated once when the module is first loaded, not per test run. Tests that use suffixes like-4, -5, -6to create uniquely-named campaigns will reuse the same base name across test retries, which can cause conflicts. Consider generating the timestamp inside abefore/beforeEach` hook or lazily within each test.
TarekAlQaddy
left a comment
There was a problem hiding this comment.
Thanks @esezen for making the changes 🙏
No description provided.