From e626373a2eea5490017273939a021fac4179f208 Mon Sep 17 00:00:00 2001
From: Mike Lei <mike.lei@flipp.com>
Date: Mon, 11 Sep 2023 11:51:42 -0700
Subject: [PATCH] Flipp Bid Adapter : initial release (#10412)

* Flipp Bid Adapter: initial release

* Added flippBidAdapter

* OFF-372 Support DTX/Hero in flippBidAdapter (#2)

* support creativeType

* OFF-422 flippBidAdapter handle AdTypes

---------

Co-authored-by: Jairo Panduro <jpanduro@blackbird-lab.com>

* OFF-465 Add getUserKey logic to prebid.js adapter (#3)

* Support cookie sync and uid

* address pr feedback

* remove redundant check

* OFF-500 Support "startCompact" param for Prebid.JS #4

* set startCompact default value (#5)

* fix docs

* use client bidding endpoint

* update unit testing endpoint

---------

Co-authored-by: Jairo Panduro <jpanduro@blackbird-lab.com>
---
 modules/flippBidAdapter.js                | 183 ++++++++++++++++++++++
 modules/flippBidAdapter.md                |  44 ++++++
 test/spec/modules/flippBidAdapter_spec.js | 170 ++++++++++++++++++++
 3 files changed, 397 insertions(+)
 create mode 100644 modules/flippBidAdapter.js
 create mode 100644 modules/flippBidAdapter.md
 create mode 100644 test/spec/modules/flippBidAdapter_spec.js

diff --git a/modules/flippBidAdapter.js b/modules/flippBidAdapter.js
new file mode 100644
index 00000000000..dfe8141170d
--- /dev/null
+++ b/modules/flippBidAdapter.js
@@ -0,0 +1,183 @@
+import {isEmpty, parseUrl} from '../src/utils.js';
+import { registerBidder } from '../src/adapters/bidderFactory.js';
+import { BANNER } from '../src/mediaTypes.js';
+import {getStorageManager} from '../src/storageManager.js';
+
+const NETWORK_ID = 11090;
+const AD_TYPES = [4309, 641];
+const DTX_TYPES = [5061];
+const TARGET_NAME = 'inline';
+const BIDDER_CODE = 'flipp';
+const ENDPOINT = 'https://gateflipp.flippback.com/flyer-locator-service/client_bidding';
+const DEFAULT_TTL = 30;
+const DEFAULT_CURRENCY = 'USD';
+const DEFAULT_CREATIVE_TYPE = 'NativeX';
+const VALID_CREATIVE_TYPES = ['DTX', 'NativeX'];
+const FLIPP_USER_KEY = 'flipp-uid';
+const COMPACT_DEFAULT_HEIGHT = 600;
+
+let userKey = null;
+export const storage = getStorageManager({bidderCode: BIDDER_CODE});
+
+export function getUserKey(options = {}) {
+  if (userKey) {
+    return userKey;
+  }
+
+  // If the partner provides the user key use it, otherwise fallback to cookies
+  if (options.userKey && isValidUserKey(options.userKey)) {
+    userKey = options.userKey;
+    return options.userKey;
+  }
+  // Grab from Cookie
+  const foundUserKey = storage.cookiesAreEnabled() && storage.getCookie(FLIPP_USER_KEY);
+  if (foundUserKey) {
+    return foundUserKey;
+  }
+
+  // Generate if none found
+  userKey = generateUUID();
+
+  // Set cookie
+  if (storage.cookiesAreEnabled()) {
+    storage.setCookie(FLIPP_USER_KEY, userKey);
+  }
+
+  return userKey;
+}
+
+function isValidUserKey(userKey) {
+  return !userKey.startsWith('#');
+}
+
+const generateUUID = () => {
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+    const r = (Math.random() * 16) | 0;
+    const v = c === 'x' ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+};
+
+/**
+ * Determines if a creativeType is valid
+ *
+ * @param {string} creativeType The Creative Type to validate.
+ * @return string creativeType if this is a valid Creative Type, and 'NativeX' otherwise.
+ */
+const validateCreativeType = (creativeType) => {
+  if (creativeType && VALID_CREATIVE_TYPES.includes(creativeType)) {
+    return creativeType;
+  } else {
+    return DEFAULT_CREATIVE_TYPE;
+  }
+};
+
+const getAdTypes = (creativeType) => {
+  if (creativeType === 'DTX') {
+    return DTX_TYPES;
+  }
+  return AD_TYPES;
+}
+
+export const spec = {
+  code: BIDDER_CODE,
+  supportedMediaTypes: [BANNER],
+  /**
+   * Determines whether or not the given bid request is valid.
+   *
+   * @param {BidRequest} bid The bid params to validate.
+   * @return boolean True if this is a valid bid, and false otherwise.
+   */
+  isBidRequestValid: function(bid) {
+    return !!(bid.params.siteId) && !!(bid.params.publisherNameIdentifier);
+  },
+  /**
+   * Make a server request from the list of BidRequests.
+   *
+   * @param {BidRequest[]} validBidRequests[] an array of bids
+   * @param {BidderRequest} bidderRequest master bidRequest object
+   * @return ServerRequest Info describing the request to the server.
+   */
+  buildRequests: function(validBidRequests, bidderRequest) {
+    const urlParams = parseUrl(bidderRequest.refererInfo.page).search;
+    const contentCode = urlParams['flipp-content-code'];
+    const userKey = getUserKey(validBidRequests[0]?.params);
+    const placements = validBidRequests.map((bid, index) => {
+      const options = bid.params.options || {};
+      if (!options.hasOwnProperty('startCompact')) {
+        options.startCompact = true;
+      }
+      return {
+        divName: TARGET_NAME,
+        networkId: NETWORK_ID,
+        siteId: bid.params.siteId,
+        adTypes: getAdTypes(bid.params.creativeType),
+        count: 1,
+        ...(!isEmpty(bid.params.zoneIds) && {zoneIds: bid.params.zoneIds}),
+        properties: {
+          ...(!isEmpty(contentCode) && {contentCode: contentCode.slice(0, 32)}),
+        },
+        options,
+        prebid: {
+          requestId: bid.bidId,
+          publisherNameIdentifier: bid.params.publisherNameIdentifier,
+          height: bid.mediaTypes.banner.sizes[index][0],
+          width: bid.mediaTypes.banner.sizes[index][1],
+          creativeType: validateCreativeType(bid.params.creativeType),
+        }
+      }
+    });
+    return {
+      method: 'POST',
+      url: ENDPOINT,
+      data: {
+        placements,
+        url: bidderRequest.refererInfo.page,
+        user: {
+          key: userKey,
+        },
+      },
+    }
+  },
+  /**
+   * Unpack the response from the server into a list of bids.
+   *
+   * @param {ServerResponse} serverResponse A successful response from the server.
+   * @param {BidRequest} bidRequest A bid request object
+   * @return {Bid[]} An array of bids which were nested inside the server.
+   */
+  interpretResponse: function(serverResponse, bidRequest) {
+    if (!serverResponse?.body) return [];
+    const placements = bidRequest.data.placements;
+    const res = serverResponse.body;
+    if (!isEmpty(res) && !isEmpty(res.decisions) && !isEmpty(res.decisions.inline)) {
+      return res.decisions.inline.map(decision => {
+        const placement = placements.find(p => p.prebid.requestId === decision.prebid?.requestId);
+        const height = placement.options?.startCompact ? COMPACT_DEFAULT_HEIGHT : decision.height;
+        return {
+          bidderCode: BIDDER_CODE,
+          requestId: decision.prebid?.requestId,
+          cpm: decision.prebid?.cpm,
+          width: decision.width,
+          height,
+          creativeId: decision.adId,
+          currency: DEFAULT_CURRENCY,
+          netRevenue: true,
+          ttl: DEFAULT_TTL,
+          ad: decision.prebid?.creative,
+        }
+      });
+    }
+    return [];
+  },
+
+  /**
+   * Register the user sync pixels which should be dropped after the auction.
+   *
+   * @param {SyncOptions} syncOptions Which user syncs are allowed?
+   * @param {ServerResponse[]} serverResponses List of server's responses.
+   * @return {UserSync[]} The user syncs which should be dropped.
+   */
+  getUserSyncs: (syncOptions, serverResponses) => [],
+}
+registerBidder(spec);
diff --git a/modules/flippBidAdapter.md b/modules/flippBidAdapter.md
new file mode 100644
index 00000000000..810b883e3f9
--- /dev/null
+++ b/modules/flippBidAdapter.md
@@ -0,0 +1,44 @@
+# Overview
+
+```
+Module Name: Flipp Bid Adapter
+Module Type: Bidder Adapter
+Maintainer: prebid@flipp.com
+```
+
+# Description
+
+This module connects publishers to Flipp's Shopper Experience via Prebid.js.
+
+
+# Test parameters
+
+```javascript
+var adUnits = [
+    {
+        code: 'flipp-scroll-ad-content',
+        mediaTypes: {
+            banner: {
+                sizes: [
+                    [300, 600]
+                ]
+            }
+        },
+        bids: [
+            {
+                bidder: 'flipp',
+                params: {
+                    creativeType: 'NativeX', // Optional, can be one of 'NativeX' (default) or 'DTX' 
+                    publisherNameIdentifier: 'wishabi-test-publisher', // Required
+                    siteId: 1192075, // Required
+                    zoneIds: [260678], // Optional
+                    userKey: "", // Optional
+                    options: {
+                        startCompact: true // Optional, default to true
+                    }
+                }
+            }
+        ]
+    }
+]
+```
diff --git a/test/spec/modules/flippBidAdapter_spec.js b/test/spec/modules/flippBidAdapter_spec.js
new file mode 100644
index 00000000000..518052ad91e
--- /dev/null
+++ b/test/spec/modules/flippBidAdapter_spec.js
@@ -0,0 +1,170 @@
+import {expect} from 'chai';
+import {spec} from 'modules/flippBidAdapter';
+import {newBidder} from 'src/adapters/bidderFactory';
+const ENDPOINT = 'https://gateflipp.flippback.com/flyer-locator-service/client_bidding';
+describe('flippAdapter', function () {
+  const adapter = newBidder(spec);
+
+  describe('inherited functions', function () {
+    it('exists and is a function', function () {
+      expect(adapter.callBids).to.exist.and.to.be.a('function');
+    });
+  });
+
+  describe('isBidRequestValid', function () {
+    const bid = {
+      bidder: 'flipp',
+      params: {
+        publisherNameIdentifier: 'random',
+        siteId: 1234,
+        zoneIds: [1, 2, 3, 4],
+      }
+    };
+    it('should return true when required params found', function () {
+      expect(spec.isBidRequestValid(bid)).to.equal(true);
+    });
+
+    it('should return false when required params are not passed', function () {
+      let invalidBid = Object.assign({}, bid);
+      invalidBid.params = { siteId: 1234 }
+      expect(spec.isBidRequestValid(invalidBid)).to.equal(false);
+    });
+  });
+
+  describe('buildRequests', function () {
+    const bidRequests = [{
+      bidder: 'flipp',
+      params: {
+        siteId: 1234,
+      },
+      adUnitCode: '/10000/unit_code',
+      sizes: [[300, 600]],
+      mediaTypes: {banner: {sizes: [[300, 600]]}},
+      bidId: '237f4d1a293f99',
+      bidderRequestId: '1a857fa34c1c96',
+      auctionId: 'a297d1aa-7900-4ce4-a0aa-caa8d46c4af7',
+      transactionId: '00b2896c-2731-4f01-83e4-7a3ad5da13b6',
+    }];
+    const bidderRequest = {
+      refererInfo: {
+        referer: 'http://example.com'
+      }
+    };
+
+    it('sends bid request to ENDPOINT via POST', function () {
+      const request = spec.buildRequests(bidRequests, bidderRequest);
+      expect(request.method).to.equal('POST');
+    });
+
+    it('sends bid request to ENDPOINT with query parameter', function () {
+      const request = spec.buildRequests(bidRequests, bidderRequest);
+      expect(request.url).to.equal(ENDPOINT);
+    });
+  });
+
+  describe('interpretResponse', function() {
+    it('should get correct bid response', function() {
+      const bidRequest = {
+        method: 'POST',
+        url: ENDPOINT,
+        data: {
+          placements: [{
+            divName: 'slot',
+            networkId: 12345,
+            siteId: 12345,
+            adTypes: [12345],
+            count: 1,
+            prebid: {
+              requestId: '237f4d1a293f99',
+              publisherNameIdentifier: 'bid.params.publisherNameIdentifier',
+              height: 600,
+              width: 300,
+            },
+            user: '10462725-da61-4d3a-beff-6d05239e9a6e"',
+          }],
+          url: 'http://example.com',
+        },
+      };
+
+      const serverResponse = {
+        body: {
+          'decisions': {
+            'inline': [{
+              'bidCpm': 1,
+              'adId': 262838368,
+              'height': 600,
+              'width': 300,
+              'storefront': { 'flyer_id': 5435567 },
+              'prebid': {
+                'requestId': '237f4d1a293f99',
+                'cpm': 1.11,
+                'creative': 'Returned from server',
+              }
+            }]
+          },
+          'location': {'city': 'Oakville'},
+        },
+      };
+
+      const expectedResponse = [
+        {
+          bidderCode: 'flipp',
+          requestId: '237f4d1a293f99',
+          currency: 'USD',
+          cpm: 1.11,
+          netRevenue: true,
+          width: 300,
+          height: 600,
+          creativeId: 262838368,
+          ttl: 30,
+          ad: 'Returned from server',
+        }
+      ];
+
+      const result = spec.interpretResponse(serverResponse, bidRequest);
+      expect(result).to.have.lengthOf(1);
+      expect(result).to.deep.have.same.members(expectedResponse);
+    });
+
+    it('should get empty bid response when no ad is returned', function() {
+      const bidRequest = {
+        method: 'POST',
+        url: ENDPOINT,
+        data: {
+          placements: [{
+            divName: 'slot',
+            networkId: 12345,
+            siteId: 12345,
+            adTypes: [12345],
+            count: 1,
+            prebid: {
+              requestId: '237f4d1a293f99',
+              publisherNameIdentifier: 'bid.params.publisherNameIdentifier',
+              height: 600,
+              width: 300,
+            },
+            user: '10462725-da61-4d3a-beff-6d05239e9a6e"',
+          }],
+          url: 'http://example.com',
+        },
+      };
+
+      const serverResponse = {
+        body: {
+          'decisions': {
+            'inline': []
+          },
+          'location': {'city': 'Oakville'},
+        },
+      };
+
+      const result = spec.interpretResponse(serverResponse, bidRequest);
+      expect(result).to.have.lengthOf(0);
+      expect(result).to.deep.have.same.members([]);
+    })
+
+    it('should get empty response when bid server returns 204', function() {
+      expect(spec.interpretResponse({})).to.be.empty;
+    });
+  });
+});