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>
This commit is contained in:
soxa 2025-02-12 03:02:04 +01:00 committed by GitHub
parent bdd87e7d39
commit 1afadbdf3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 127 additions and 28 deletions

View File

@ -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`

View File

@ -124,9 +124,12 @@ export default gql`
withdrawl: Withdrawl!
}
union ReferralSource = Item | Sub | User
type Referral {
id: ID!
sortTime: Date!
source: ReferralSource
}
type SubStatus {

View File

@ -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} />}
</>
)
}

View File

@ -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

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OneDayReferral" ADD COLUMN "landing" BOOLEAN NOT NULL DEFAULT false;

View File

@ -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])

View File

@ -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`