From 1afadbdf3b3342a57ae546446f65472576b93ce5 Mon Sep 17 00:00:00 2001
From: soxa <6390896+Soxasora@users.noreply.github.com>
Date: Wed, 12 Feb 2025 03:02:04 +0100
Subject: [PATCH] enhance: referral notifications with source (#1862)

* wip: referral notification shows source of referral

* simpler approach for source info gathering

* fix territory representation; fix fragment field

* cleanup; fix UI

* better margin approach

* hotfix: null check

* add support for comments

* use Union to represent ReferralSource; clarify with switch statements

* cleanup: compact switch statement on Referral resolver

* wip use refereeLanding

* add comments; cleanup

* hotfix: backwards compatibility for Earnings calculation

* small copy and semantics changes

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
---
 api/resolvers/notifications.js                | 18 +++++++
 api/typeDefs/notifications.js                 |  3 ++
 components/notifications.js                   | 25 ++++++++--
 fragments/notifications.js                    | 12 +++++
 middleware.js                                 | 45 +++++++++++------
 pages/api/auth/[...nextauth].js               | 48 +++++++++++++++----
 .../migration.sql                             |  2 +
 prisma/schema.prisma                          |  1 +
 worker/earn.js                                |  1 +
 9 files changed, 127 insertions(+), 28 deletions(-)
 create mode 100644 prisma/migrations/20250207171157_record_referee_landing/migration.sql

diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
index 26e8c487..33e58702 100644
--- a/api/resolvers/notifications.js
+++ b/api/resolvers/notifications.js
@@ -467,6 +467,24 @@ export default {
       return subAct.subName
     }
   },
+  ReferralSource: {
+    __resolveType: async (n, args, { models }) => n.type
+  },
+  Referral: {
+    source: async (n, args, { models, me }) => {
+      // retrieve the referee landing record
+      const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
+      if (!referral) return null // if no landing record, it will return a generic referral
+
+      switch (referral.type) {
+        case 'POST':
+        case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
+        case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
+        case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
+        default: return null
+      }
+    }
+  },
   Streak: {
     days: async (n, args, { models }) => {
       const res = await models.$queryRaw`
diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js
index 4eabb356..d416f01c 100644
--- a/api/typeDefs/notifications.js
+++ b/api/typeDefs/notifications.js
@@ -124,9 +124,12 @@ export default gql`
     withdrawl: Withdrawl!
   }
 
+  union ReferralSource = Item | Sub | User
+
   type Referral {
     id: ID!
     sortTime: Date!
+    source: ReferralSource
   }
 
   type SubStatus {
diff --git a/components/notifications.js b/components/notifications.js
index 5d4c0419..97561d49 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -44,6 +44,7 @@ import classNames from 'classnames'
 import HolsterIcon from '@/svgs/holster.svg'
 import SaddleIcon from '@/svgs/saddle.svg'
 import CCInfo from './info/cc'
+import { useMe } from './me'
 
 function Notification ({ n, fresh }) {
   const type = n.__typename
@@ -528,11 +529,27 @@ function WithdrawlPaid ({ n }) {
 }
 
 function Referral ({ n }) {
+  const { me } = useMe()
+  let referralSource = 'of you'
+  switch (n.source?.__typename) {
+    case 'Item':
+      referralSource = (Number(me?.id) === Number(n.source.user?.id) ? 'of your' : 'you shared this') + ' ' + (n.source.title ? 'post' : 'comment')
+      break
+    case 'Sub':
+      referralSource = (Number(me?.id) === Number(n.source.userId) ? 'of your' : 'you shared the') + ' ~' + n.source.name + ' territory'
+      break
+    case 'User':
+      referralSource = (me?.name === n.source.name ? 'of your profile' : `you shared ${n.source.name}'s profile`)
+      break
+  }
   return (
-    <small className='fw-bold text-success'>
-      <UserAdd className='fill-success me-2' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because of you
-      <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
-    </small>
+    <>
+      <small className='fw-bold text-success'>
+        <UserAdd className='fill-success me-1' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because {referralSource}
+        <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
+      </small>
+      {n.source?.__typename === 'Item' && <NoteItem itemClassName='pt-2' item={n.source} />}
+    </>
   )
 }
 
diff --git a/fragments/notifications.js b/fragments/notifications.js
index 0915ccdf..e9a91e35 100644
--- a/fragments/notifications.js
+++ b/fragments/notifications.js
@@ -112,6 +112,18 @@ export const NOTIFICATIONS = gql`
         ... on Referral {
           id
           sortTime
+          source {
+            __typename
+            ... on Item {
+              ...ItemFullFields
+            }
+            ... on Sub {
+              ...SubFields
+            }
+            ... on User {
+              name
+            }
+          }
         }
         ... on Reply {
           id
diff --git a/middleware.js b/middleware.js
index d99464c3..f88bda31 100644
--- a/middleware.js
+++ b/middleware.js
@@ -9,6 +9,26 @@ const territoryPattern = new URLPattern({ pathname: '/~:name([\\w_]+){/*}?' })
 const SN_REFERRER = 'sn_referrer'
 // we use this to hold /r/... referrers through the redirect
 const SN_REFERRER_NONCE = 'sn_referrer_nonce'
+// key for referred pages
+const SN_REFEREE_LANDING = 'sn_referee_landing'
+
+function getContentReferrer (request, url) {
+  if (itemPattern.test(url)) {
+    let id = request.nextUrl.searchParams.get('commentId')
+    if (!id) {
+      ({ id } = itemPattern.exec(url).pathname.groups)
+    }
+    return `item-${id}`
+  }
+  if (profilePattern.test(url)) {
+    const { name } = profilePattern.exec(url).pathname.groups
+    return `profile-${name}`
+  }
+  if (territoryPattern.test(url)) {
+    const { name } = territoryPattern.exec(url).pathname.groups
+    return `territory-${name}`
+  }
+}
 
 // we store the referrers in cookies for a future signup event
 // we pass the referrers in the request headers so we can use them in referral rewards for logged in stackers
@@ -25,6 +45,14 @@ function referrerMiddleware (request) {
     // referrers. Content referrers do not override explicit referrers because
     // explicit referees might click around before signing up.
     response.cookies.set(SN_REFERRER, referrer, { maxAge: 60 * 60 * 24 })
+
+    // we record the first page the user lands on and keep it for 24 hours
+    // in addition to the explicit referrer, this allows us to tell the referrer
+    // which share link the user clicked on
+    const contentReferrer = getContentReferrer(request, url)
+    if (contentReferrer) {
+      response.cookies.set(SN_REFEREE_LANDING, contentReferrer, { maxAge: 60 * 60 * 24 })
+    }
     // store the explicit referrer for one page load
     // this allows us to attribute both explicit and implicit referrers after the redirect
     // e.g. items/<num>/r/<referrer> links should attribute both the item op and the referrer
@@ -33,22 +61,9 @@ function referrerMiddleware (request) {
     return response
   }
 
-  let contentReferrer
-  if (itemPattern.test(request.url)) {
-    let id = request.nextUrl.searchParams.get('commentId')
-    if (!id) {
-      ({ id } = itemPattern.exec(request.url).pathname.groups)
-    }
-    contentReferrer = `item-${id}`
-  } else if (profilePattern.test(request.url)) {
-    const { name } = profilePattern.exec(request.url).pathname.groups
-    contentReferrer = `profile-${name}`
-  } else if (territoryPattern.test(request.url)) {
-    const { name } = territoryPattern.exec(request.url).pathname.groups
-    contentReferrer = `territory-${name}`
-  }
+  const contentReferrer = getContentReferrer(request, request.url)
 
-  // pass the referrers to SSR in the request headers
+  // pass the referrers to SSR in the request headers for one day referrer attribution
   const requestHeaders = new Headers(request.headers)
   const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
   if (referrers.length) {
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index f3773a67..7e3c466f 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -40,20 +40,46 @@ function getEventCallbacks () {
   }
 }
 
-async function getReferrerId (referrer) {
+async function getReferrerFromCookie (referrer) {
+  let referrerId
+  let type
+  let typeId
   try {
     if (referrer.startsWith('item-')) {
-      return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } }))?.userId
+      const item = await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
+      type = item?.parentId ? 'COMMENT' : 'POST'
+      referrerId = item?.userId
+      typeId = item?.id
     } else if (referrer.startsWith('profile-')) {
-      return (await prisma.user.findUnique({ where: { name: referrer.slice(8) } }))?.id
+      const user = await prisma.user.findUnique({ where: { name: referrer.slice(8) } })
+      type = 'PROFILE'
+      referrerId = user?.id
+      typeId = user?.id
     } else if (referrer.startsWith('territory-')) {
-      return (await prisma.sub.findUnique({ where: { name: referrer.slice(10) } }))?.userId
+      type = 'TERRITORY'
+      typeId = referrer.slice(10)
+      const sub = await prisma.sub.findUnique({ where: { name: typeId } })
+      referrerId = sub?.userId
     } else {
-      return (await prisma.user.findUnique({ where: { name: referrer } }))?.id
+      return {
+        referrerId: (await prisma.user.findUnique({ where: { name: referrer } }))?.id
+      }
     }
   } catch (error) {
     console.error('error getting referrer id', error)
+    return
   }
+  return { referrerId, type, typeId: String(typeId) }
+}
+
+async function getReferrerData (referrer, landing) {
+  const referrerData = await getReferrerFromCookie(referrer)
+  if (landing) {
+    const landingData = await getReferrerFromCookie(landing)
+    // explicit referrer takes precedence over landing referrer
+    return { ...landingData, ...referrerData }
+  }
+  return referrerData
 }
 
 /** @returns {Partial<import('next-auth').CallbacksOptions>} */
@@ -77,10 +103,14 @@ function getCallbacks (req, res) {
         // isNewUser doesn't work for nostr/lightning auth because we create the user before nextauth can
         // this means users can update their referrer if they don't have one, which is fine
         if (req.cookies.sn_referrer && user?.id) {
-          const referrerId = await getReferrerId(req.cookies.sn_referrer)
-          if (referrerId && referrerId !== parseInt(user?.id)) {
-            const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } })
-            if (count > 0) notifyReferral(referrerId)
+          const referrerData = await getReferrerData(req.cookies.sn_referrer, req.cookies.sn_referee_landing)
+          if (referrerData?.referrerId && referrerData.referrerId !== parseInt(user?.id)) {
+            // if we have recorded a referee landing, record it in the db
+            if (referrerData.type && referrerData.typeId) {
+              await prisma.oneDayReferral.create({ data: { ...referrerData, refereeId: user.id, landing: true } })
+            }
+            const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId: referrerData.referrerId } })
+            if (count > 0) notifyReferral(referrerData.referrerId)
           }
         }
       }
diff --git a/prisma/migrations/20250207171157_record_referee_landing/migration.sql b/prisma/migrations/20250207171157_record_referee_landing/migration.sql
new file mode 100644
index 00000000..eb796654
--- /dev/null
+++ b/prisma/migrations/20250207171157_record_referee_landing/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "OneDayReferral" ADD COLUMN     "landing" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8e0e64ea..2ec20fce 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -176,6 +176,7 @@ model OneDayReferral {
   referee    User               @relation("OneDayReferral_referrees", fields: [refereeId], references: [id], onDelete: Cascade)
   type       OneDayReferralType
   typeId     String
+  landing    Boolean            @default(false)
 
   @@index([createdAt])
   @@index([referrerId])
diff --git a/worker/earn.js b/worker/earn.js
index 8c41b093..8fde43ba 100644
--- a/worker/earn.js
+++ b/worker/earn.js
@@ -74,6 +74,7 @@ export async function earn ({ name }) {
       FROM earners
       LEFT JOIN "OneDayReferral" ON "OneDayReferral"."refereeId" = earners."userId"
       WHERE "OneDayReferral".created_at >= date_trunc('day', now() AT TIME ZONE 'America/Chicago' - interval '1 day')
+      AND "OneDayReferral".landing IS NOT TRUE
       GROUP BY earners."userId", earners."foreverReferrerId", earners.proportion, earners.rank
       ORDER BY rank ASC`