Implement campaigns module with support for the Campaigns API#257
Implement campaigns module with support for the Campaigns API#257Sher-Bakhodirov wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Campaigns module to the ConstructorIO Node client, exposing CRUD operations for the Campaigns (Searchandising) API and wiring it into both runtime code and TypeScript typings, with an accompanying test suite.
Changes:
- Added
Campaignsmodule (retrieveCampaigns,retrieveCampaign,createCampaign,updateCampaign,deleteCampaign) and registered it on the mainConstructorIOclient. - Added TypeScript definitions for the Campaigns API and re-exported them from the types index.
- Added a Mocha test suite covering the new campaigns endpoints plus updated cspell dictionary.
Reviewed changes
Copilot reviewed 4 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/index.d.ts | Re-exports Campaigns typings. |
| src/types/constructorio.d.ts | Adds campaigns property typing to the main client. |
| src/types/campaigns.d.ts | Introduces Campaigns API parameter/response typings. |
| src/modules/campaigns.js | Implements Campaigns API client methods and URL builder. |
| src/constructorio.js | Wires campaigns module into the ConstructorIO client instance. |
| spec/src/modules/campaigns.js | Adds integration tests for Campaigns CRUD + headers/timeout behaviors. |
| cspell.json | Adds “timeofday” to allowlist. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Pull number of results per page from parameters | ||
| if (numResultsPerPage) { | ||
| queryParams.num_results_per_page = numResultsPerPage; | ||
| } | ||
|
|
||
| // Pull page from parameters | ||
| if (page) { | ||
| queryParams.page = page; | ||
| } | ||
|
|
||
| // Pull offset from parameters | ||
| if (offset) { | ||
| queryParams.offset = offset; | ||
| } |
| // Save id of an existing campaign for the following tests below | ||
| await campaigns.retrieveCampaigns({ numResultsPerPage: 1 }).then((res) => { | ||
| if (res.campaigns && res.campaigns.length) { | ||
| campaignId = res.campaigns[0].id; | ||
| } | ||
| }); |
There was a problem hiding this comment.
Code Review
This PR implements a well-structured campaigns module for the Constructor.io Campaigns API, following the existing module patterns, with comprehensive integration tests and TypeScript type definitions.
Inline comments: 8 discussions added
Overall Assessment:
| 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, but in CreateCampaignParameters and UpdateCampaignParameters the same field is typed as RecommendationContext[] (an array). This inconsistency is likely a bug — the JS implementation uses it as a single object for query params but an array for the POST/PATCH body. Confirm the intended shape for each operation and ensure types match the actual API behavior.
| }); | ||
| }); | ||
|
|
||
| it('Should return error when deleting a campaign with an invalid API key', () => { |
There was a problem hiding this comment.
Important Issue: The deleteCampaign error-path tests (invalid API key / invalid API token) use the outer suite-level campaignId rather than creating a dedicated campaign to delete. This risks deleting the shared campaignId if the API returns a successful (2xx) response for some reason, or causes a 404 during subsequent tests that depend on campaignId still existing. Use createCampaignToDelete() (already defined in this describe block) consistently for the error-path tests as well, for proper test isolation.
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!
| section?: string; | ||
| } | ||
|
|
||
| export interface CreateCampaignParameters extends Record<string, any> { |
There was a problem hiding this comment.
Is there a reason for extending Record<String, any> here? I know we use Record<string, any> in other type definitions, but we definitely don't want to continue this practice since it goes against the main benefit of typescript
| 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; | |
| } |
| 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?
|
|
||
| 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.
| }); | ||
|
|
||
| // Clean up campaigns created during the suite (in parallel to stay within the hook timeout) | ||
| await Promise.all(createdCampaignIds.map((id) => campaigns.deleteCampaign({ id }).catch(() => {}))); |
No description provided.