diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 01b7055..fd66b8f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -23,7 +23,7 @@ jobs: with: go-version: "^1.22.3" - - run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d + - run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build - run: npm ci - run: npx prisma generate diff --git a/api/t.go b/api/t.go index 201a425..c5b33de 100644 --- a/api/t.go +++ b/api/t.go @@ -66,15 +66,19 @@ func T(w http.ResponseWriter, r *http.Request) { ipInfoData = <-ipidch } + trafficSource := <-tsch + tsTokens := trafficSource.MakeTokens(*r.URL) + publicClickId := pkg.NewPublicClickID() dest, _ := campaign.DetermineViewDestination(pkg.DestinationOpts{ - R: *r, - Ctx: ctx, - Storer: *tStorer, - SavedFlow: savedFlow, - UserAgent: userAgent, - IpInfoData: ipInfoData, - PublicClickId: publicClickId, + R: *r, + Ctx: ctx, + Storer: *tStorer, + SavedFlow: savedFlow, + UserAgent: userAgent, + IpInfoData: ipInfoData, + TrafficSourceTokens: tsTokens, + PublicClickId: publicClickId, }) anch := make(chan pkg.AffiliateNetwork) @@ -93,7 +97,6 @@ func T(w http.ResponseWriter, r *http.Request) { if !visitorNeedsIpInfoData { ipInfoData = <-ipidch } - trafficSource := <-tsch affiliateNetwork := <-anch // Save click to db @@ -106,7 +109,7 @@ func T(w http.ResponseWriter, r *http.Request) { ClickTime: getClicktime(dest, timestamp), ViewOutputURL: dest.URL, ClickOutputURL: getClickOutputURL(dest), - Tokens: trafficSource.MakeTokens(*r.URL), + Tokens: tsTokens, IP: r.RemoteAddr, Isp: ipInfoData.Org, UserAgent: r.UserAgent(), diff --git a/cypress.config.ts b/cypress.config.ts index 9a6ddf5..d13e07e 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ setupNodeEvents(on, config) { // implement node event listeners here }, + specPattern: [ + "cypress/e2e/redirects.cy.ts", + "cypress/e2e/dashboard.cy.ts", + ], }, env: { // Non-sensitive env vars hard-coded here. Example: diff --git a/cypress/e2e/dashboard.cy.ts b/cypress/e2e/dashboard.cy.ts new file mode 100644 index 0000000..ae2ab44 --- /dev/null +++ b/cypress/e2e/dashboard.cy.ts @@ -0,0 +1,29 @@ +import seedData, { returnFirstOrThrow } from "../../prisma/seedData"; +import { Env } from "../../src/lib/types"; + +describe("Testing dashboard functionality", () => { + it("logs in successfully and traverses dashboard functionality", () => { + cy.visit("http://localhost:3000"); + cy.url().should("eq", "http://localhost:3000/login"); + + cy.get("[data-cy='username-input']").type(Cypress.env(Env.ROOT_USERNAME)); + cy.get("[data-cy='password-input']").type(Cypress.env(Env.ROOT_PASSWORD)); + cy.get("[data-cy='submit-button']").click(); + + cy.wait(1000 * 10); + + cy.url().should("eq", "http://localhost:3000/dashboard"); + + const { name } = returnFirstOrThrow(seedData.campaignSeeds, "Campaign seed"); + cy.get(`[data-cy='${name}']`).click(); + cy.get("[data-cy='report-button']").click(); + + cy.wait(1000 * 10); + + const { customTokens } = returnFirstOrThrow(seedData.trafficSourceSeeds, "Traffic Source seed"); + for (const token of customTokens) { + cy.get("[data-cy='select-chain-link-index-0']").select(token.queryParam); + cy.wait(1000); + } + }); +}); diff --git a/cypress/e2e/redirects.cy.ts b/cypress/e2e/redirects.cy.ts index 7c2fa83..e9f31cf 100644 --- a/cypress/e2e/redirects.cy.ts +++ b/cypress/e2e/redirects.cy.ts @@ -1,12 +1,19 @@ -import { campaignSeedData, landingPageSeedData, offerSeedData } from "../../prisma/seedData"; import { makeCampaignUrl, makeClickUrl, makePostbackUrl } from "../../src/lib/utils"; import { ECookieName, Env } from "../../src/lib/types"; +import { ECustomTokenParam, returnAtIndexOrThrow, returnFirstOrThrow, testUserAgent, testZoneId } from "../../prisma/seedData"; +import seedData from "../../prisma/seedData"; +const { campaignSeeds, landingPageSeeds, offerSeeds, trafficSourceSeeds } = seedData; describe("Testing campaign redirects", () => { + const { publicId } = returnFirstOrThrow(campaignSeeds, "Campaign seed"); + const { customTokens } = returnFirstOrThrow(trafficSourceSeeds, "Traffic Source seed"); + it("redirects to the correct URLs", () => { // Campaign URL - cy.visit(makeCampaignUrl("http:", "localhost", "3001", campaignSeedData.publicId, [])); - cy.url().should("eq", landingPageSeedData.url); + cy.visit(makeCampaignUrl("http:", "localhost", "3001", publicId, [])); + + const { url } = returnFirstOrThrow(landingPageSeeds, "Landing Page seed"); + cy.url().should("eq", url); cy.getCookie(ECookieName.CLICK_PUBLIC_ID, { domain: "localhost" }) .then(cookie => { @@ -18,7 +25,9 @@ describe("Testing campaign redirects", () => { // Click URL cy.visit(makeClickUrl("http:", "localhost", "3001")); - cy.url().should("eq", offerSeedData.url); + + const { url } = returnFirstOrThrow(offerSeeds, "Offer seed"); + cy.url().should("eq", url); //Postback URL cy.request(makePostbackUrl("http:", "localhost", "3001", pid || "")) @@ -38,4 +47,36 @@ describe("Testing campaign redirects", () => { cy.visit(makeClickUrl("http:", "localhost", "3001")); cy.url().should("eq", Cypress.env(Env.CATCH_ALL_REDIRECT_URL)); }); + + it("redirects to the correct rule route per user agent header", () => { + cy.visit( + makeCampaignUrl("http:", "localhost", "3001", publicId, []), + { + headers: { + "User-Agent": testUserAgent, + }, + }, + ); + + const { url } = returnAtIndexOrThrow(offerSeeds, 1, "Offer seed"); + cy.url().should("eq", url); + }); + + it("redirects to the correct rule route per custom traffic source token", () => { + const tokens = [ + { + queryParam: ECustomTokenParam.ZONE_ID, + value: testZoneId, + }, + { + queryParam: ECustomTokenParam.BANNER_ID, + value: "", + }, + ]; + + cy.visit(makeCampaignUrl("http:", "localhost", "3001", publicId, tokens)); + + const { url } = returnAtIndexOrThrow(offerSeeds, 2, "Offer seed"); + cy.url().should("eq", url); + }); }); diff --git a/docker-compose.yml b/docker-compose.yml index 25104f9..f57ba5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,9 @@ services: build: context: ./ dockerfile: ./Dockerfile + environment: + - "POSTGRES_URL=postgresql://postgres:mysecretpassword@postgres:5432/mydb?schema=public" + - "REDIS_URL=redis://redis:6379" ports: - "3000:3000" depends_on: diff --git a/pkg/campaign.go b/pkg/campaign.go index 0dbc908..8d389e8 100644 --- a/pkg/campaign.go +++ b/pkg/campaign.go @@ -77,8 +77,8 @@ func (s *Storer) GetCampaignByPublicId(ctx context.Context, publicId string) (Ca return c, nil } -func (c *Campaign) SelectViewRoute(r http.Request, userAgent useragent.UserAgent, ipInfoData IPInfoData) Route { - return selectViewRoute(c.FlowMainRoute, c.FlowRuleRoutes, r, userAgent, ipInfoData) +func (c *Campaign) SelectViewRoute(r http.Request, userAgent useragent.UserAgent, ipInfoData IPInfoData, tokens []Token) Route { + return selectViewRoute(c.FlowMainRoute, c.FlowRuleRoutes, r, userAgent, ipInfoData, tokens) } // Checks if the click triggered any rule routes, and if not returns the main route @@ -99,13 +99,14 @@ func (c *Campaign) SelectOfferID(ids []int) (int, error) { } type DestinationOpts struct { - R http.Request - Ctx context.Context - Storer Storer - SavedFlow SavedFlow - UserAgent useragent.UserAgent - IpInfoData IPInfoData - PublicClickId string + R http.Request + Ctx context.Context + Storer Storer + SavedFlow SavedFlow + UserAgent useragent.UserAgent + IpInfoData IPInfoData + TrafficSourceTokens []Token + PublicClickId string } func (do *DestinationOpts) TokenMatcherMap() URLTokenMatcherMap { @@ -138,9 +139,9 @@ func (c *Campaign) DetermineViewDestination(opts DestinationOpts) (Destination, } else if c.FlowType == db.FlowTypeBuiltIn || c.FlowType == db.FlowTypeSaved { route := Route{} if c.FlowType == db.FlowTypeBuiltIn { - route = c.SelectViewRoute(opts.R, opts.UserAgent, opts.IpInfoData) + route = c.SelectViewRoute(opts.R, opts.UserAgent, opts.IpInfoData, opts.TrafficSourceTokens) } else { - route = opts.SavedFlow.SelectViewRoute(opts.R, opts.UserAgent, opts.IpInfoData) + route = opts.SavedFlow.SelectViewRoute(opts.R, opts.UserAgent, opts.IpInfoData, opts.TrafficSourceTokens) } path, err := route.WeightedSelectPath() diff --git a/pkg/campaign_test.go b/pkg/campaign_test.go new file mode 100644 index 0000000..5b328a6 --- /dev/null +++ b/pkg/campaign_test.go @@ -0,0 +1,214 @@ +package pkg + +import ( + "net/http" + "testing" + + "github.com/EricFrancis12/evoclick/prisma/db" + "github.com/mileusna/useragent" + "github.com/stretchr/testify/assert" +) + +func TestCampaign(t *testing.T) { + var mainRoute = Route{ + // The LogicalRelation field is irrelevant for the mainRoute. + // It is being defined here as an "anchor" to ensure accurate comparison of mainRoute == ruleRoute. + // mainRoute -> LogicalRelationOr + // ruleRoute -> LogicalRelationAnd + LogicalRelation: LogicalRelationOr, + Paths: []Path{ + { + IsActive: true, + Weight: 50, + LandingPageIDs: []int{1, 2, 3}, + OfferIDs: []int{1, 2, 3}, + }, + }, + } + + t.Run("Redirects to main route over rule routes by default", func(t *testing.T) { + ruleRoute := Route{ + IsActive: true, + LogicalRelation: LogicalRelationAnd, + } + campaign := Campaign{ + FlowMainRoute: mainRoute, + FlowRuleRoutes: []Route{ruleRoute}, + } + + selectedViewRoute := campaign.SelectViewRoute(http.Request{}, useragent.UserAgent{}, IPInfoData{}, []Token{}) + + assert.Equal(t, selectedViewRoute, ruleRoute) + assert.Equal(t, selectedViewRoute, campaign.FlowRuleRoutes[0]) + assert.NotEqual(t, selectedViewRoute, mainRoute) + assert.NotEqual(t, selectedViewRoute, campaign.FlowMainRoute) + + selectedClickRoute := campaign.SelectClickRoute(Click{}) + + assert.Equal(t, selectedClickRoute, ruleRoute) + assert.Equal(t, selectedClickRoute, campaign.FlowRuleRoutes[0]) + assert.NotEqual(t, selectedClickRoute, mainRoute) + assert.NotEqual(t, selectedClickRoute, campaign.FlowMainRoute) + }) + t.Run("Test campaign.SelectViewRoute() with non-custom RuleName", func(t *testing.T) { + var ( + ruleRoute = Route{ + IsActive: true, + LogicalRelation: LogicalRelationAnd, + Rules: []Rule{ + { + RuleName: RuleNameOS, + Data: []string{"Windows"}, + Includes: true, + }, + }, + Paths: []Path{ + { + IsActive: true, + Weight: 100, + }, + }, + } + ) + + campaign := Campaign{ + FlowMainRoute: mainRoute, + FlowRuleRoutes: []Route{ruleRoute}, + } + + selectedRoute := campaign.SelectViewRoute(http.Request{}, useragent.UserAgent{OS: "Windows"}, IPInfoData{}, []Token{}) + + assert.Equal(t, selectedRoute, ruleRoute) + assert.Equal(t, selectedRoute, campaign.FlowRuleRoutes[0]) + assert.NotEqual(t, selectedRoute, mainRoute) + assert.NotEqual(t, selectedRoute, campaign.FlowMainRoute) + }) + + t.Run("Test campaign.SelectViewRoute() with custom RuleName (from traffic source custom tokens)", func(t *testing.T) { + var ( + queryParam = "zone_id" + value = "87654321" + ruleRoute = Route{ + IsActive: true, + LogicalRelation: LogicalRelationAnd, + Rules: []Rule{ + { + RuleName: toCustomRuleName(queryParam), + Data: []string{value}, + Includes: true, + }, + }, + Paths: []Path{ + { + IsActive: true, + Weight: 100, + }, + }, + } + ) + + campaign := Campaign{ + FlowMainRoute: mainRoute, + FlowRuleRoutes: []Route{ruleRoute}, + } + + tokens := []Token{ + { + QueryParam: queryParam, + Value: value, + }, + } + + selectedRoute := campaign.SelectViewRoute(http.Request{}, useragent.UserAgent{}, IPInfoData{}, tokens) + + assert.Equal(t, selectedRoute, ruleRoute) + assert.Equal(t, selectedRoute, campaign.FlowRuleRoutes[0]) + assert.NotEqual(t, selectedRoute, mainRoute) + assert.NotEqual(t, selectedRoute, campaign.FlowMainRoute) + }) + + t.Run("Test campaign.SelectClickRoute() with non-custom RuleName", func(t *testing.T) { + var ( + ruleRoute = Route{ + IsActive: true, + LogicalRelation: LogicalRelationAnd, + Rules: []Rule{ + { + RuleName: RuleNameOS, + Data: []string{"Windows"}, + Includes: true, + }, + }, + Paths: []Path{ + { + IsActive: true, + Weight: 100, + }, + }, + } + ) + + campaign := Campaign{ + FlowMainRoute: mainRoute, + FlowRuleRoutes: []Route{ruleRoute}, + } + + click := Click{ + InnerClick: db.InnerClick{ + Os: "Windows", + }, + } + + selectedRoute := campaign.SelectClickRoute(click) + + assert.Equal(t, selectedRoute, ruleRoute) + assert.Equal(t, selectedRoute, campaign.FlowRuleRoutes[0]) + assert.NotEqual(t, selectedRoute, mainRoute) + assert.NotEqual(t, selectedRoute, campaign.FlowMainRoute) + }) + + t.Run("Test campaign.SelectClickRoute() with custom RuleName (from traffic source custom tokens)", func(t *testing.T) { + var ( + queryParam = "zone_id" + value = "87654321" + ruleRoute = Route{ + IsActive: true, + LogicalRelation: LogicalRelationAnd, + Rules: []Rule{ + { + RuleName: toCustomRuleName(queryParam), + Data: []string{value}, + Includes: true, + }, + }, + Paths: []Path{ + { + IsActive: true, + Weight: 100, + }, + }, + } + ) + + campaign := Campaign{ + FlowMainRoute: mainRoute, + FlowRuleRoutes: []Route{ruleRoute}, + } + + click := Click{ + Tokens: []Token{ + { + QueryParam: queryParam, + Value: value, + }, + }, + } + + selectedRoute := campaign.SelectClickRoute(click) + + assert.Equal(t, selectedRoute, ruleRoute) + assert.Equal(t, selectedRoute, campaign.FlowRuleRoutes[0]) + assert.NotEqual(t, selectedRoute, mainRoute) + assert.NotEqual(t, selectedRoute, campaign.FlowMainRoute) + }) +} diff --git a/pkg/flow.go b/pkg/flow.go index 54441e7..8d80d1e 100644 --- a/pkg/flow.go +++ b/pkg/flow.go @@ -44,8 +44,8 @@ func (s *Storer) GetSavedFlowById(ctx context.Context, id int) (SavedFlow, error return fl, nil } -func (sf *SavedFlow) SelectViewRoute(r http.Request, userAgent useragent.UserAgent, ipInfoData IPInfoData) Route { - return selectViewRoute(sf.MainRoute, sf.RuleRoutes, r, userAgent, ipInfoData) +func (sf *SavedFlow) SelectViewRoute(r http.Request, userAgent useragent.UserAgent, ipInfoData IPInfoData, tokens []Token) Route { + return selectViewRoute(sf.MainRoute, sf.RuleRoutes, r, userAgent, ipInfoData, tokens) } func (sf *SavedFlow) SelectClickRoute(click Click) Route { diff --git a/pkg/route.go b/pkg/route.go index 009e5f1..df4a167 100644 --- a/pkg/route.go +++ b/pkg/route.go @@ -58,9 +58,9 @@ func (route Route) doesTrigger(condition func(rule Rule) bool) bool { } // Determines if the view triggers any rules in this route -func (route Route) ViewDoesTrigger(r http.Request, ua useragent.UserAgent, ipInfoData IPInfoData) bool { +func (route Route) ViewDoesTrigger(r http.Request, ua useragent.UserAgent, ipInfoData IPInfoData, tokens []Token) bool { return route.doesTrigger(func(rule Rule) bool { - return rule.ViewDoesTrigger(r, ua, ipInfoData) + return rule.ViewDoesTrigger(r, ua, ipInfoData, tokens) }) } @@ -72,13 +72,13 @@ func (route Route) ClickDoesTrigger(click Click) bool { } // Checks if the click triggered any rule routes, and if not returns the main route -func selectViewRoute(mainRoute Route, ruleRoutes []Route, r http.Request, userAgent useragent.UserAgent, ipInfoData IPInfoData) Route { +func selectViewRoute(mainRoute Route, ruleRoutes []Route, r http.Request, userAgent useragent.UserAgent, ipInfoData IPInfoData, tokens []Token) Route { route := mainRoute for _, ruleRoute := range ruleRoutes { if !ruleRoute.IsActive { continue } - if ruleRoute.ViewDoesTrigger(r, userAgent, ipInfoData) { + if ruleRoute.ViewDoesTrigger(r, userAgent, ipInfoData, tokens) { route = ruleRoute break } diff --git a/pkg/rule.go b/pkg/rule.go index 5b30d59..3212bce 100644 --- a/pkg/rule.go +++ b/pkg/rule.go @@ -9,8 +9,8 @@ import ( type RulesMap map[RuleName]string // Determines if the view triggers this rule -func (rule Rule) ViewDoesTrigger(r http.Request, ua useragent.UserAgent, ipInfoData IPInfoData) bool { - rulesMap := newRulesMapFromView(r, ua, ipInfoData) +func (rule Rule) ViewDoesTrigger(r http.Request, ua useragent.UserAgent, ipInfoData IPInfoData, tokens []Token) bool { + rulesMap := newRulesMapFromView(r, ua, ipInfoData, tokens) return rulesMap.checkForMatch(rule) } @@ -20,6 +20,7 @@ func (rule Rule) ClickDoesTrigger(click Click) bool { return rulesMap.checkForMatch(rule) } +// Determine if a rule triggers this RulesMap func (rm RulesMap) checkForMatch(rule Rule) bool { for _, str := range rule.Data { if rm[rule.RuleName] == str { @@ -29,9 +30,17 @@ func (rm RulesMap) checkForMatch(rule Rule) bool { return !rule.Includes } +func toCustomRuleName(queryParam string) string { + return "Custom-Rule-" + queryParam +} + +func (t Token) CustomRuleName() string { + return toCustomRuleName(t.QueryParam) +} + // Helper function to create RulesMap from request, user agent, and ipInfoData -func newRulesMapFromView(r http.Request, ua useragent.UserAgent, ipInfoData IPInfoData) RulesMap { - return RulesMap{ +func newRulesMapFromView(r http.Request, ua useragent.UserAgent, ipInfoData IPInfoData, tokens []Token) RulesMap { + rm := RulesMap{ RuleNameBrowserName: ua.Name, RuleNameBrowserVersion: ua.Version, RuleNameCity: ipInfoData.City, @@ -47,11 +56,20 @@ func newRulesMapFromView(r http.Request, ua useragent.UserAgent, ipInfoData IPIn RuleNameScreenResolution: GetScreenRes(r), RuleNameUserAgent: r.UserAgent(), } + + // Loop through query string grabbing additional query params, + // then check the corresponding tokens for any matches. + // And if so, add them to the RulesMap. + for _, token := range tokens { + rm[token.CustomRuleName()] = token.Value + } + + return rm } // Helper function to create RulesMap from click func newRulesMapFromClick(click Click) RulesMap { - return RulesMap{ + rm := RulesMap{ RuleNameBrowserName: click.BrowserName, RuleNameBrowserVersion: click.BrowserVersion, RuleNameCity: click.City, @@ -67,4 +85,13 @@ func newRulesMapFromClick(click Click) RulesMap { RuleNameScreenResolution: click.ScreenResolution, RuleNameUserAgent: click.UserAgent, } + + // Loop through query string grabbing additional query params, + // then check click.Tokens for any matches. + // And if so, add them to the RulesMap. + for _, token := range click.Tokens { + rm[token.CustomRuleName()] = token.Value + } + + return rm } diff --git a/pkg/rule_test.go b/pkg/rule_test.go index 11aa7fb..e99793e 100644 --- a/pkg/rule_test.go +++ b/pkg/rule_test.go @@ -2,6 +2,7 @@ package pkg import ( "net/http" + "net/url" "testing" "github.com/EricFrancis12/evoclick/prisma/db" @@ -9,62 +10,107 @@ import ( "github.com/stretchr/testify/assert" ) -var browsers = []string{"Chrome", "Edge", "Safari"} - -func TestViewDoesTrigger(t *testing.T) { - assert.False(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: true, - }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{}, IPInfoData{})) - - assert.True(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: false, - }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{}, IPInfoData{})) - - assert.True(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: true, - }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{Name: browsers[0]}, IPInfoData{})) - - assert.False(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: false, - }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{Name: browsers[0]}, IPInfoData{})) -} +func TestRules(t *testing.T) { + var browsers = []string{"Chrome", "Edge", "Safari"} + + t.Run("Test ViewDoesTrigger with non-custom RuleName", func(t *testing.T) { + assert.False(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: true, + }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{}, IPInfoData{}, []Token{})) + + assert.True(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: false, + }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{}, IPInfoData{}, []Token{})) + + assert.True(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: true, + }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{Name: browsers[0]}, IPInfoData{}, []Token{})) + + assert.False(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: false, + }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{Name: browsers[0]}, IPInfoData{}, []Token{})) + }) + + t.Run("Test ViewDoesTrigger with custom RuleName (from traffic source custom tokens)", func(t *testing.T) { + var ( + queryParam = "zone_id" + value = "87654321" + token = Token{ + QueryParam: queryParam, + Value: value, + } + ruleName RuleName = token.CustomRuleName() + data = []string{value} + ) + + assert.False(t, Rule{ + RuleName: ruleName, + Data: []string{}, + Includes: true, + }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{}, IPInfoData{}, []Token{token})) + + assert.True(t, Rule{ + RuleName: ruleName, + Data: []string{}, + Includes: false, + }.ViewDoesTrigger(http.Request{}, useragent.UserAgent{}, IPInfoData{}, []Token{token})) + + r := http.Request{ + URL: &url.URL{ + RawQuery: queryParam + "=" + value, + }, + } + + assert.True(t, Rule{ + RuleName: ruleName, + Data: data, + Includes: true, + }.ViewDoesTrigger(r, useragent.UserAgent{}, IPInfoData{}, []Token{token})) + + assert.False(t, Rule{ + RuleName: ruleName, + Data: data, + Includes: false, + }.ViewDoesTrigger(r, useragent.UserAgent{}, IPInfoData{}, []Token{token})) + }) + + t.Run("Test ClickDoesTrigger", func(t *testing.T) { + click := Click{ + InnerClick: db.InnerClick{ + BrowserName: browsers[0], + }, + } + + assert.False(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: true, + }.ClickDoesTrigger(Click{})) + + assert.True(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: false, + }.ClickDoesTrigger(Click{})) + + assert.True(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: true, + }.ClickDoesTrigger(click)) -func TestClickDoesTrigger(t *testing.T) { - click := Click{ - InnerClick: db.InnerClick{ - BrowserName: browsers[0], - }, - } - - assert.False(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: true, - }.ClickDoesTrigger(Click{})) - - assert.True(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: false, - }.ClickDoesTrigger(Click{})) - - assert.True(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: true, - }.ClickDoesTrigger(click)) - - assert.False(t, Rule{ - RuleName: RuleNameBrowserName, - Data: browsers, - Includes: false, - }.ClickDoesTrigger(click)) + assert.False(t, Rule{ + RuleName: RuleNameBrowserName, + Data: browsers, + Includes: false, + }.ClickDoesTrigger(click)) + }) } diff --git a/pkg/trafficSource.go b/pkg/trafficSource.go index 6c27cd0..69508d5 100644 --- a/pkg/trafficSource.go +++ b/pkg/trafficSource.go @@ -79,10 +79,9 @@ func (ts *TrafficSource) SendPostback(click Click, pbrch chan PostbackResult) { // creating a Token for them if they are listed as custom tokens on the traffic source func (ts *TrafficSource) MakeTokens(url url.URL) []Token { tokens := []Token{} - query := url.Query() - for key, val := range query { - for _, tstoken := range ts.CustomTokens { - if key == tstoken.QueryParam { + for key, val := range url.Query() { + for _, namedToken := range ts.CustomTokens { + if key == namedToken.QueryParam { tokens = append(tokens, Token{ QueryParam: key, Value: SafeFirstString(val), diff --git a/pkg/types.go b/pkg/types.go index f852d13..d9539b3 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -169,10 +169,10 @@ const ( LogicalRelationOr LogicalRelation = "or" ) -type RuleName string +type RuleName = string const ( - RuleNameIP RuleName = "IP" + RuleNameIP string = "IP" RuleNameISP RuleName = "ISP" RuleNameUserAgent RuleName = "User Agent" RuleNameLanguage RuleName = "Language" diff --git a/prisma/main.ts b/prisma/main.ts index bd73758..60be9cf 100644 --- a/prisma/main.ts +++ b/prisma/main.ts @@ -1,59 +1,190 @@ import { $Enums } from "@prisma/client"; import prisma from "../src/lib/db"; -import { TSeedData } from "./seedData"; +import { returnAtIndexOrThrow, returnFirstOrThrow, TSeedData, testUserAgent, testZoneId, ECustomTokenParam } from "./seedData"; +import { ELogicalRelation, ERuleName, TRoute } from "../src/lib/types"; export default async function main(seedData: TSeedData) { const { - affiliateNetworkSeedData, campaignSeedData, savedFlowSeedData, - landingPageSeedData, offerSeedData, trafficSourceSeedData, + affiliateNetworkSeeds, campaignSeeds, landingPageSeeds, + offerSeeds, savedFlowSeeds, trafficSourceSeeds, } = seedData; + // Seed Affiliate Networks + const affiliateNetworkSeed = returnFirstOrThrow(affiliateNetworkSeeds, "Affiliate Network seed"); + const affiliateNetwork = await prisma.affiliateNetwork.create({ - data: affiliateNetworkSeedData, + data: affiliateNetworkSeed, + }); + + for (let i = 1; i < affiliateNetworkSeeds.length; i++) { + const seed = affiliateNetworkSeeds[i]; + if (!seed) continue; + await prisma.affiliateNetwork.create({ + data: seed, + }); + } + + // Seed Offers + const offerSeed1 = returnAtIndexOrThrow(offerSeeds, 0, "Offer seed"); + const offer1 = await prisma.offer.create({ + data: { + ...offerSeed1, + affiliateNetworkId: affiliateNetwork.id, + }, }); - const offer = await prisma.offer.create({ + const offerSeed2 = returnAtIndexOrThrow(offerSeeds, 1, "Offer seed"); + const offer2 = await prisma.offer.create({ data: { - ...offerSeedData, + ...offerSeed2, affiliateNetworkId: affiliateNetwork.id, }, }); + const offerSeed3 = returnAtIndexOrThrow(offerSeeds, 2, "Offer seed"); + const offer3 = await prisma.offer.create({ + data: { + ...offerSeed3, + affiliateNetworkId: affiliateNetwork.id, + }, + }); + + for (let i = 2; i < offerSeeds.length; i++) { + const seed = offerSeeds[i]; + if (!seed) continue; + await prisma.offer.create({ + data: { + ...seed, + affiliateNetworkId: affiliateNetwork.id, + }, + }); + } + + // Seed Landing Pages + const landingPageSeed = returnFirstOrThrow(landingPageSeeds, "Landing Page seed"); + const landingPage = await prisma.landingPage.create({ - data: landingPageSeedData, + data: landingPageSeed, }); + for (let i = 1; i < landingPageSeeds.length; i++) { + const seed = landingPageSeeds[i]; + if (!seed) continue; + await prisma.landingPage.create({ + data: seed, + }); + } + + // Seed Saved Flows + const savedFlowSeed = returnFirstOrThrow(savedFlowSeeds, "Saved Flow seed"); + + const mainRoute: TRoute = { + ...savedFlowSeed.mainRoute, + paths: [ + { + directLinkingEnabled: false, + isActive: true, + landingPageIds: [landingPage.id], + offerIds: [offer1.id], + weight: 100, + }, + ], + }; + + const ruleRoutes: TRoute[] = [ + { + isActive: true, + logicalRelation: ELogicalRelation.AND, + rules: [ + { + ruleName: ERuleName.USER_AGENT, + data: [testUserAgent], + includes: true, + }, + ], + paths: [ + { + isActive: true, + weight: 100, + landingPageIds: [], + offerIds: [offer2.id], + directLinkingEnabled: true, + }, + ], + }, + { + isActive: true, + logicalRelation: ELogicalRelation.AND, + rules: [ + { + ruleName: `Custom-Rule-${ECustomTokenParam.ZONE_ID}`, + data: [testZoneId], + includes: true, + }, + ], + paths: [ + { + isActive: true, + weight: 100, + landingPageIds: [], + offerIds: [offer3.id], + directLinkingEnabled: true, + }, + ], + }, + ]; + const flow = await prisma.savedFlow.create({ data: { - ...savedFlowSeedData, - mainRoute: JSON.stringify({ - ...savedFlowSeedData.mainRoute, - paths: [ - { - directLinkingEnabled: false, - isActive: true, - landingPageIds: [landingPage.id], - offerIds: [offer.id], - weight: 100, - }, - ], - }), - ruleRoutes: JSON.stringify(savedFlowSeedData.ruleRoutes), + ...savedFlowSeed, + mainRoute: JSON.stringify(mainRoute), + ruleRoutes: JSON.stringify(ruleRoutes), }, }); + for (let i = 1; i < savedFlowSeeds.length; i++) { + const seed = savedFlowSeeds[i]; + if (!seed) continue; + await prisma.savedFlow.create({ + data: { + ...savedFlowSeed, + mainRoute: JSON.stringify(mainRoute), + ruleRoutes: JSON.stringify(ruleRoutes), + }, + }); + } + + // Seed Traffic Sources + const trafficSourceSeed = returnFirstOrThrow(trafficSourceSeeds, "Traffic Source seed"); + const trafficSource = await prisma.trafficSource.create({ data: { - ...trafficSourceSeedData, - externalIdToken: JSON.stringify(trafficSourceSeedData.externalIdToken), - costToken: JSON.stringify(trafficSourceSeedData.costToken), - customTokens: JSON.stringify(trafficSourceSeedData.customTokens), + ...trafficSourceSeed, + externalIdToken: JSON.stringify(trafficSourceSeed.externalIdToken), + costToken: JSON.stringify(trafficSourceSeed.costToken), + customTokens: JSON.stringify(trafficSourceSeed.customTokens), }, }); + for (let i = 1; i < trafficSourceSeeds.length; i++) { + const seed = trafficSourceSeeds[i]; + if (!seed) continue; + await prisma.trafficSource.create({ + data: { + ...trafficSourceSeed, + externalIdToken: JSON.stringify(trafficSourceSeed.externalIdToken), + costToken: JSON.stringify(trafficSourceSeed.costToken), + customTokens: JSON.stringify(trafficSourceSeed.customTokens), + }, + }); + } + + // Seed Campaigns + const campaignSeed = returnFirstOrThrow(campaignSeeds, "Campaign seed"); + const campaign = await prisma.campaign.create({ data: { - ...campaignSeedData, + ...campaignSeed, flowType: $Enums.FlowType.SAVED, savedFlowId: flow.id, trafficSourceId: trafficSource.id, @@ -63,12 +194,28 @@ export default async function main(seedData: TSeedData) { }, }); + for (let i = 1; i < campaignSeeds.length; i++) { + const seed = campaignSeeds[i]; + if (!seed) continue; + await prisma.campaign.create({ + data: { + ...campaignSeed, + flowType: $Enums.FlowType.SAVED, + savedFlowId: flow.id, + trafficSourceId: trafficSource.id, + flowMainRoute: "", + flowRuleRoutes: "", + flowUrl: "", + }, + }); + } + return { affiliateNetwork, campaign, flow, landingPage, - offer, + offer: offer1, trafficSource, }; } diff --git a/prisma/seedData.ts b/prisma/seedData.ts index 7a699d9..0b13243 100644 --- a/prisma/seedData.ts +++ b/prisma/seedData.ts @@ -1,86 +1,161 @@ import { $Enums } from "@prisma/client"; -import { ELogicalRelation, TNamedToken, TRoute, TToken } from "../src/lib/types"; +import { ELogicalRelation, ERuleName, TNamedToken, TRoute, TToken } from "../src/lib/types"; -const tags = ["placeholder", "example"]; +export const testUserAgent = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; +export const testZoneId = "87654321"; + +export enum ECustomTokenParam { + BANNER_ID = "banner_id", + ZONE_ID = "zone_id", +}; + +export type TAffiliateNetworkSeed = { + name: string; + defaultNewOfferString: string; + tags: string[]; +}; + +export type TOfferSeed = { + name: string; + url: string; + tags: string[]; +}; + +export type TLandingPageSeed = { + name: string; + url: string; + tags: string[]; +}; -export const affiliateNetworkSeedData = { - name: "My First Affiliate Network", - defaultNewOfferString: "", - tags, +export type TSavedFlowSeed = { + name: string; + mainRoute: TRoute; + ruleRoutes: TRoute[]; + tags: string[]; }; -export type TAffiliateNetworkSeedData = typeof affiliateNetworkSeedData; -export const offerSeedData = { - name: "My First Offer", - url: "http://localhost:3001/public/sample-offer.html", - tags, +export type TTrafficSourceSeed = { + name: string; + postbackUrl: string; + externalIdToken: TToken; + costToken: TToken; + customTokens: TNamedToken[]; + tags: string[]; }; -export type TOfferSeedData = typeof offerSeedData; -export const landingPageSeedData = { - name: "My First Landing Page", - url: "http://localhost:3001/public/lp/sample-landing-page.html", - tags, +export type TCampaignSeed = { + name: string; + publicId: string; + landingPageRotationType: $Enums.RotationType; + offerRotationType: $Enums.RotationType; + geoName: $Enums.GeoName; + tags: string[]; }; -export type TLandingPageSeedData = typeof landingPageSeedData; -export const savedFlowSeedData = { - name: "My First Saved Flow", - mainRoute: { - isActive: true, - logicalRelation: ELogicalRelation.AND, - rules: [], - paths: [], - }, - ruleRoutes: [], - tags, +export type TSeedData = { + affiliateNetworkSeeds: TAffiliateNetworkSeed[], + offerSeeds: TOfferSeed[], + landingPageSeeds: TLandingPageSeed[], + savedFlowSeeds: TSavedFlowSeed[], + trafficSourceSeeds: TTrafficSourceSeed[], + campaignSeeds: TCampaignSeed[], }; -export type TSavedFlowSeedData = typeof savedFlowSeedData; -export const trafficSourceSeedData = { - externalIdToken: { - queryParam: "external_id", - value: "{external_id}", - }, - costToken: { - queryParam: "cost", - value: "{cost}", - }, - customTokens: [ +export function returnAtIndexOrThrow(arr: T[], index: number, name: string): T { + const result = arr[index]; + if (result === undefined) { + throw new Error(`missing ${name} at index ${index}`); + } + return result; +} + +export function returnFirstOrThrow(arr: T[], name: string): T { + return returnAtIndexOrThrow(arr, 0, name); +} + +const tags = ["placeholder", "example"]; + +const seedData: TSeedData = { + affiliateNetworkSeeds: [ + { + name: "My First Affiliate Network", + defaultNewOfferString: "", + tags, + }, + ], + offerSeeds: [ + { + name: "My First Offer", + url: "http://localhost:3001/public/sample-offer.html?src=my-first-offer", + tags, + }, { - name: "Zone ID", - queryParam: "zone_id", - value: "{zone_id}" + name: "My Second Offer", + url: "http://localhost:3001/public/sample-offer.html?src=my-second-offer", + tags, }, { - name: "Banner ID", - queryParam: "banner_id", - value: "{banner_id}" + name: "My Third Offer", + url: "http://localhost:3001/public/sample-offer.html?src=my-third-offer", + tags, + }, + ], + landingPageSeeds: [ + { + name: "My First Landing Page", + url: "http://localhost:3001/public/lp/sample-landing-page.html", + tags, + }, + ], + savedFlowSeeds: [ + { + name: "My First Saved Flow", + mainRoute: { + isActive: true, + logicalRelation: ELogicalRelation.AND, + rules: [], + paths: [], + }, + ruleRoutes: [], + tags, + }, + ], + trafficSourceSeeds: [ + { + name: "My First Traffic Source", + postbackUrl: "http://localhost:3001/public/sample-postback-url.html", + externalIdToken: { + queryParam: "external_id", + value: "{external_id}", + }, + costToken: { + queryParam: "cost", + value: "{cost}", + }, + customTokens: [ + { + name: "Zone ID", + queryParam: ECustomTokenParam.ZONE_ID, + value: "{zone_id}", + }, + { + name: "Banner ID", + queryParam: ECustomTokenParam.BANNER_ID, + value: "{banner_id}", + }, + ], + tags, + }, + ], + campaignSeeds: [ + { + name: "My First Campaign", + publicId: "1234-abcd-5678-efgh", + landingPageRotationType: $Enums.RotationType.RANDOM, + offerRotationType: $Enums.RotationType.RANDOM, + geoName: $Enums.GeoName.UNITED_STATES, + tags, }, ], - name: "My First Traffic Source", - postbackUrl: "http://localhost:3001/public/sample-postback-url.html", - tags, -}; -export type TTrafficSourceSeedData = typeof trafficSourceSeedData; - -export const campaignSeedData = { - name: "My First Campaign", - publicId: "1234-abcd-5678-efgh", - landingPageRotationType: $Enums.RotationType.RANDOM, - offerRotationType: $Enums.RotationType.RANDOM, - geoName: $Enums.GeoName.UNITED_STATES, - tags, -}; -export type TCampaignSeedData = typeof campaignSeedData; - -const seedData = { - affiliateNetworkSeedData, - campaignSeedData, - savedFlowSeedData, - landingPageSeedData, - offerSeedData, - trafficSourceSeedData, }; -export type TSeedData = typeof seedData; export default seedData; diff --git a/scripts/3rd_party_api_test.ts b/scripts/3rd_party_api_test.ts index 967f6e4..6da5d7c 100644 --- a/scripts/3rd_party_api_test.ts +++ b/scripts/3rd_party_api_test.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { IPInfoDataSchema } from "../src/lib/schemas"; import { iPInfoEndpoint } from "../src/lib/utils"; -import { dotenvConfig } from "@/lib/utils/env"; +import { dotenvConfig } from "../src/lib/utils/env"; import { Env } from "../src/lib/types"; dotenvConfig(); diff --git a/scripts/load_test.ts b/scripts/load_test.ts index 2d4eb76..e77a589 100644 --- a/scripts/load_test.ts +++ b/scripts/load_test.ts @@ -1,15 +1,17 @@ import { argv } from "process"; import http from "http"; -import { campaignSeedData } from "../prisma/seedData"; +import seedData, { returnFirstOrThrow } from "../prisma/seedData"; import { makeCampaignUrl } from "../src/lib/utils"; -import { dotenvConfig } from "@/lib/utils/env"; +import { dotenvConfig } from "../src/lib/utils/env"; import { Env } from "../src/lib/types"; dotenvConfig(); if (!process.env[Env.API_PORT]) throw new Error(`Environment variable ${Env.API_PORT} not set.`); -const URL = makeCampaignUrl("http:", "localhost", process.env[Env.API_PORT], campaignSeedData.publicId, []); +const { publicId } = returnFirstOrThrow(seedData.campaignSeeds, "Campaign seed"); + +const URL = makeCampaignUrl("http:", "localhost", process.env[Env.API_PORT], publicId, []); const DURATION = 30_000; // ms (async function () { diff --git a/scripts/seed_clicks.ts b/scripts/seed_clicks.ts index 875c2b3..f22649e 100644 --- a/scripts/seed_clicks.ts +++ b/scripts/seed_clicks.ts @@ -3,7 +3,7 @@ import crypto from "crypto"; import { Prisma } from "@prisma/client"; import db from "../src/lib/db"; import { randomItemFromArray, randomIntInRange } from "../src/lib/utils"; -import { dotenvConfig } from "@/lib/utils/env"; +import { dotenvConfig } from "../src/lib/utils/env"; dotenvConfig(); diff --git a/scripts/seed_data.ts b/scripts/seed_data.ts index 0df80a1..f85e6dd 100644 --- a/scripts/seed_data.ts +++ b/scripts/seed_data.ts @@ -1,16 +1,21 @@ import crypto from "crypto"; import main from "../prisma/main"; -import seedData from "../prisma/seedData"; +import seedData, { returnFirstOrThrow } from "../prisma/seedData"; (async function () { try { console.log("Starting seed"); + + const campaignSeed = returnFirstOrThrow(seedData.campaignSeeds, "Campaign seed"); + await main({ ...seedData, - campaignSeedData: { - ...seedData.campaignSeedData, - publicId: crypto.randomUUID(), - } + campaignSeeds: [ + { + ...campaignSeed, + publicId: crypto.randomUUID(), + }, + ], }); console.log("Seed finished with no errors"); } catch (err) { diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index b6ff95d..447cd5d 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -27,11 +27,12 @@ export default function LoginForm() { action={handleLoginAction} className="flex flex-col gap-1" > - - + + @@ -39,15 +40,11 @@ export default function LoginForm() { ) } -function Input({ name, type }: { - name: string; - type: string; -}) { +function Input(props: React.ComponentPropsWithoutRef<"input">) { return ( ) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index ddc3ebe..f1e19ea 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,5 +1,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { Dataset } from "@/lib/types"; export const BUTTON_STYLE: React.CSSProperties = { border: "solid lightgrey 1px", @@ -7,13 +8,14 @@ export const BUTTON_STYLE: React.CSSProperties = { backgroundImage: "linear-gradient(0deg,var(--color-gray5),var(--color-white))", }; -export default function Button({ children, disabled, icon, onClick, text, className }: { - children?: React.ReactNode, - disabled?: boolean, - icon?: IconDefinition, - onClick: React.MouseEventHandler, - text?: string, - className?: string +export default function Button({ children, disabled, icon, onClick, text, className, dataset }: { + children?: React.ReactNode; + disabled?: boolean; + icon?: IconDefinition; + onClick: React.MouseEventHandler; + text?: string; + className?: string; + dataset?: Dataset; }) { function handleClick(e: React.MouseEvent) { if (disabled) return; @@ -26,6 +28,7 @@ export default function Button({ children, disabled, icon, onClick, text, classN className={(!disabled ? "cursor-pointer hover:opacity-70" : "opacity-40") + " flex justify-center items-center gap-2 px-2 py-2 border"} style={BUTTON_STYLE} + {...dataset} > {icon && } {text && {text}} diff --git a/src/components/base.tsx b/src/components/base.tsx index 6bc553f..ae29f6f 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -1,5 +1,7 @@ "use client"; +import { Dataset } from "@/lib/types"; + const BASE_COMPONENT_CLASSNAME = "w-full px-2 py-1"; const BASE_COMPONENT_STYLE = { border: "solid 1px grey", @@ -29,7 +31,7 @@ export function Input({ name = "", placeholder, value, onChange }: { ) } -export function Select({ name = "", value, onChange, children, disabled, className, style }: { +export function Select({ name = "", value, onChange, children, disabled, className, style, dataset }: { name?: string; value: string | number | readonly string[] | undefined; onChange: React.ChangeEventHandler; @@ -37,6 +39,7 @@ export function Select({ name = "", value, onChange, children, disabled, classNa disabled?: boolean; className?: string; style?: React.CSSProperties; + dataset?: Dataset; }) { return ( @@ -49,6 +52,7 @@ export function Select({ name = "", value, onChange, children, disabled, classNa style={{ ...BASE_COMPONENT_STYLE, ...style }} value={value} onChange={onChange} + {...dataset} > {children} diff --git a/src/hooks/useRows.ts b/src/hooks/useRows.ts index 6d9eb93..bbcca24 100644 --- a/src/hooks/useRows.ts +++ b/src/hooks/useRows.ts @@ -4,20 +4,24 @@ import { useState, useEffect } from "react"; import { itemNameToClickProp } from "@/lib/utils/maps"; import { useDataContext } from "@/contexts/DataContext"; import { TRow } from "@/views/ReportView/DataTable"; -import { EItemName, TClick, TPrimaryItemName, TPrimaryData } from "@/lib/types"; +import { EItemName, TClick, TPrimaryItemName, TPrimaryData, TToken } from "@/lib/types"; import { getPrimaryItemById, isPrimary } from "@/lib/utils"; +import { reportChainValueToItemName, TReportChainValue } from "@/views/ReportView/ReportChain"; const INCLUDE_UNKNOWN_ROWS = false; -export function useRows(clicks: TClick[], itemName: EItemName): [TRow[] | null, React.Dispatch>] { +export function useRows( + clicks: TClick[], + reportChainValue: TReportChainValue, +): [TRow[] | null, React.Dispatch>] { const { primaryData } = useDataContext(); const [rows, setRows] = useState(null); useEffect(() => { - const newRows = makeRows(primaryData, clicks, itemName, makeEnrichmentItems(itemName, primaryData)); + const newRows = makeRows(primaryData, clicks, reportChainValue, makeEnrichmentItems(reportChainValue, primaryData)); setRows(newRows); - }, [clicks.length, primaryData, itemName]); + }, [clicks.length, primaryData, reportChainValue]); return [rows, setRows]; } @@ -30,15 +34,27 @@ type TEnrichmentItem = { export function makeRows( primaryData: TPrimaryData, clicks: TClick[], - itemName: EItemName, - enrichmentItems?: TEnrichmentItem[] + reportChainValue: TReportChainValue, + enrichmentItems?: TEnrichmentItem[], ): TRow[] { const rows = new Map(); - const { primaryItemName } = isPrimary(itemName); + + const { itemName } = reportChainValueToItemName(reportChainValue); + + let primaryItemName: TPrimaryItemName | null = null; + if (itemName) { + primaryItemName = isPrimary(itemName).primaryItemName; + } for (const click of clicks) { - const clickProp = itemNameToClickProp(itemName); - const value = click[clickProp]; + let value: string | number | Date | TToken[] | null; + if (itemName) { + const clickProp = itemNameToClickProp(itemName); + value = click[clickProp]; + } else { + // Traverse click.tokens to find token that matches reportChainValue + value = click.tokens.find(token => token.queryParam === reportChainValue)?.value ?? null; + } if (typeof value === "number" || typeof value === "string") { if (!rows.has(value)) { @@ -72,7 +88,11 @@ export function makeRows( return Array.from(rows.values()); } -function newRowName(primaryData: TPrimaryData, primaryItemName: TPrimaryItemName | null, value: string | number): string { +function newRowName( + primaryData: TPrimaryData, + primaryItemName: TPrimaryItemName | null, + value: string | number, +): string { if (typeof value === "number" && primaryItemName !== null) { const primaryItem = getPrimaryItemById(primaryData, primaryItemName, value); if (primaryItem) return primaryItem.name; @@ -83,9 +103,15 @@ function newRowName(primaryData: TPrimaryData, primaryItemName: TPrimaryItemName return ""; } +function makeEnrichmentItems( + reportChainValue: TReportChainValue, + primaryData: TPrimaryData, +): TEnrichmentItem[] | undefined { + const { itemName, success } = reportChainValueToItemName(reportChainValue); + if (!success) return undefined; -function makeEnrichmentItems(itemName: EItemName, primaryData: TPrimaryData): TEnrichmentItem[] | undefined { const { primaryItemName } = isPrimary(itemName); if (!primaryItemName) return undefined; + return primaryData[primaryItemName]?.map(({ id, name }) => ({ id, name: name || "" })); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 27e7493..93a35f7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,6 +13,8 @@ export enum Env { CATCH_ALL_REDIRECT_URL = "CATCH_ALL_REDIRECT_URL", }; +export type Dataset = { [key: `data-${string}`]: string }; + type omissions = "id" | "createdAt" | "updatedAt"; type primaryItemName = "primaryItemName"; type publicId = "publicId"; @@ -127,8 +129,10 @@ export enum ELogicalRelation { OR = "or", }; +export type TCustomRuleName = `Custom-Rule-${string}`; + export type TRule = { - ruleName: ERuleName; + ruleName: ERuleName | TCustomRuleName; data: string[]; includes: boolean; }; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index bcee5ec..8a6f989 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,5 +1,5 @@ import { startOfDay, addDays } from "date-fns"; -import { TPrimaryData, EItemName, EQueryParam, TPrimaryItemName, TToken } from "../types"; +import { TPrimaryData, EItemName, EQueryParam, TPrimaryItemName, TToken, TCustomRuleName } from "../types"; export * from "./maps" export * from "./new"; @@ -122,6 +122,35 @@ export function isPrimary(itemName: EItemName): isPrimaryResult { }; } +export const CUSTOM_RULE_ = "Custom-Rule-"; + +export function toCustomRuleName(queryParam: string): TCustomRuleName { + return `${CUSTOM_RULE_}${queryParam}`; +} + +type isCustomRuleNameResult = { + ok: true; + customRuleName: TCustomRuleName; +} | { + ok: false; + customRuleName: null; +}; + +export function isCustomRuleName(str: string): isCustomRuleNameResult { + const correctPrefix = str.substring(0, CUSTOM_RULE_.length) == CUSTOM_RULE_; + const nonEmptyValueAfterPrefix = str.substring(CUSTOM_RULE_.length).length > 0; + if (correctPrefix && nonEmptyValueAfterPrefix) { + return { + ok: true, + customRuleName: str as TCustomRuleName, + }; + } + return { + ok: false, + customRuleName: null, + } +} + export function makeCampaignUrl( protocol: string, hostname: string, diff --git a/src/lib/utils/new.ts b/src/lib/utils/new.ts index f73f850..27fec24 100644 --- a/src/lib/utils/new.ts +++ b/src/lib/utils/new.ts @@ -3,7 +3,7 @@ import { TOfferActionMenu, TSavedFlowActionMenu, TTrafficSourceActionMenu } from "@/views/ReportView/ActionMenu/types"; import { - EItemName, ELogicalRelation, ERuleName, TAffiliateNetwork, TCampaign, TLandingPage, + EItemName, ELogicalRelation, ERuleName, TAffiliateNetwork, TCampaign, TCustomRuleName, TLandingPage, TNamedToken, TOffer, TPath, TRoute, TRule, TSavedFlow, TToken, TTrafficSource } from "../types"; import { isPrimary } from "."; @@ -27,7 +27,7 @@ export function newPath(): TPath { }; } -export function newRule(ruleName: ERuleName): TRule { +export function newRule(ruleName: ERuleName | TCustomRuleName): TRule { return { ruleName, data: [], diff --git a/src/views/ReportView/ActionMenu/ActionMenuBody/CampaignBody.tsx b/src/views/ReportView/ActionMenu/ActionMenuBody/CampaignBody.tsx index 1d2d8b5..b22717c 100644 --- a/src/views/ReportView/ActionMenu/ActionMenuBody/CampaignBody.tsx +++ b/src/views/ReportView/ActionMenu/ActionMenuBody/CampaignBody.tsx @@ -12,9 +12,22 @@ import ActionMenuBodyWrapper from "../ActionMenuBodyWrapper"; import ActionMenuFooter from "../ActionMenuFooter"; import { newRoute } from "@/lib/utils/new"; import { TActionMenu, TCampaignActionMenu } from "../types"; -import { EItemName, TSavedFlow, TTrafficSource } from "@/lib/types"; +import { TSavedFlow, TToken, TTrafficSource } from "@/lib/types"; import { $Enums } from "@prisma/client"; +function useTrafficSourceTokens(trafficSources: TTrafficSource[], trafficSourceId?: number): TToken[] { + const [tokens, setTokens] = useState([]); + + useEffect(() => { + const trafficSource = trafficSources.find(({ id }) => id === trafficSourceId); + if (trafficSource) { + setTokens(trafficSource.customTokens); + } + }, [trafficSourceId, trafficSources, trafficSources.length]); + + return tokens; +} + export default function CampaignBody({ actionMenu, setActionMenu }: { actionMenu: TCampaignActionMenu; setActionMenu: React.Dispatch>; @@ -24,6 +37,8 @@ export default function CampaignBody({ actionMenu, setActionMenu }: { const [flowBuilderOpen, setFlowBuilderOpen] = useState(false); + const tokens = useTrafficSourceTokens(trafficSources, actionMenu.trafficSourceId); + useEffect(() => { getAllTrafficSourcesAction() .then(_trafficSources => setTrafficSources(_trafficSources)) @@ -180,6 +195,7 @@ export default function CampaignBody({ actionMenu, setActionMenu }: { flowMainRoute: mainRoute, flowRuleRoutes: ruleRoutes, })} + tokens={tokens} />