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:
parent
bdd87e7d39
commit
1afadbdf3b
@ -467,6 +467,24 @@ export default {
|
|||||||
return subAct.subName
|
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: {
|
Streak: {
|
||||||
days: async (n, args, { models }) => {
|
days: async (n, args, { models }) => {
|
||||||
const res = await models.$queryRaw`
|
const res = await models.$queryRaw`
|
||||||
|
@ -124,9 +124,12 @@ export default gql`
|
|||||||
withdrawl: Withdrawl!
|
withdrawl: Withdrawl!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
union ReferralSource = Item | Sub | User
|
||||||
|
|
||||||
type Referral {
|
type Referral {
|
||||||
id: ID!
|
id: ID!
|
||||||
sortTime: Date!
|
sortTime: Date!
|
||||||
|
source: ReferralSource
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubStatus {
|
type SubStatus {
|
||||||
|
@ -44,6 +44,7 @@ import classNames from 'classnames'
|
|||||||
import HolsterIcon from '@/svgs/holster.svg'
|
import HolsterIcon from '@/svgs/holster.svg'
|
||||||
import SaddleIcon from '@/svgs/saddle.svg'
|
import SaddleIcon from '@/svgs/saddle.svg'
|
||||||
import CCInfo from './info/cc'
|
import CCInfo from './info/cc'
|
||||||
|
import { useMe } from './me'
|
||||||
|
|
||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
@ -528,11 +529,27 @@ function WithdrawlPaid ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Referral ({ 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 (
|
return (
|
||||||
|
<>
|
||||||
<small className='fw-bold text-success'>
|
<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
|
<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 className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</small>
|
</small>
|
||||||
|
{n.source?.__typename === 'Item' && <NoteItem itemClassName='pt-2' item={n.source} />}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,6 +112,18 @@ export const NOTIFICATIONS = gql`
|
|||||||
... on Referral {
|
... on Referral {
|
||||||
id
|
id
|
||||||
sortTime
|
sortTime
|
||||||
|
source {
|
||||||
|
__typename
|
||||||
|
... on Item {
|
||||||
|
...ItemFullFields
|
||||||
|
}
|
||||||
|
... on Sub {
|
||||||
|
...SubFields
|
||||||
|
}
|
||||||
|
... on User {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
... on Reply {
|
... on Reply {
|
||||||
id
|
id
|
||||||
|
@ -9,6 +9,26 @@ const territoryPattern = new URLPattern({ pathname: '/~:name([\\w_]+){/*}?' })
|
|||||||
const SN_REFERRER = 'sn_referrer'
|
const SN_REFERRER = 'sn_referrer'
|
||||||
// we use this to hold /r/... referrers through the redirect
|
// we use this to hold /r/... referrers through the redirect
|
||||||
const SN_REFERRER_NONCE = 'sn_referrer_nonce'
|
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 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
|
// 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
|
// referrers. Content referrers do not override explicit referrers because
|
||||||
// explicit referees might click around before signing up.
|
// explicit referees might click around before signing up.
|
||||||
response.cookies.set(SN_REFERRER, referrer, { maxAge: 60 * 60 * 24 })
|
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
|
// store the explicit referrer for one page load
|
||||||
// this allows us to attribute both explicit and implicit referrers after the redirect
|
// 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
|
// 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
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentReferrer
|
const contentReferrer = getContentReferrer(request, request.url)
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 requestHeaders = new Headers(request.headers)
|
||||||
const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
|
const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
|
||||||
if (referrers.length) {
|
if (referrers.length) {
|
||||||
|
@ -40,20 +40,46 @@ function getEventCallbacks () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getReferrerId (referrer) {
|
async function getReferrerFromCookie (referrer) {
|
||||||
|
let referrerId
|
||||||
|
let type
|
||||||
|
let typeId
|
||||||
try {
|
try {
|
||||||
if (referrer.startsWith('item-')) {
|
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-')) {
|
} 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-')) {
|
} 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 {
|
} else {
|
||||||
return (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
return {
|
||||||
|
referrerId: (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('error getting referrer id', 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>} */
|
/** @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
|
// 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
|
// this means users can update their referrer if they don't have one, which is fine
|
||||||
if (req.cookies.sn_referrer && user?.id) {
|
if (req.cookies.sn_referrer && user?.id) {
|
||||||
const referrerId = await getReferrerId(req.cookies.sn_referrer)
|
const referrerData = await getReferrerData(req.cookies.sn_referrer, req.cookies.sn_referee_landing)
|
||||||
if (referrerId && referrerId !== parseInt(user?.id)) {
|
if (referrerData?.referrerId && referrerData.referrerId !== parseInt(user?.id)) {
|
||||||
const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } })
|
// if we have recorded a referee landing, record it in the db
|
||||||
if (count > 0) notifyReferral(referrerId)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OneDayReferral" ADD COLUMN "landing" BOOLEAN NOT NULL DEFAULT false;
|
@ -176,6 +176,7 @@ model OneDayReferral {
|
|||||||
referee User @relation("OneDayReferral_referrees", fields: [refereeId], references: [id], onDelete: Cascade)
|
referee User @relation("OneDayReferral_referrees", fields: [refereeId], references: [id], onDelete: Cascade)
|
||||||
type OneDayReferralType
|
type OneDayReferralType
|
||||||
typeId String
|
typeId String
|
||||||
|
landing Boolean @default(false)
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([referrerId])
|
@@index([referrerId])
|
||||||
|
@ -74,6 +74,7 @@ export async function earn ({ name }) {
|
|||||||
FROM earners
|
FROM earners
|
||||||
LEFT JOIN "OneDayReferral" ON "OneDayReferral"."refereeId" = earners."userId"
|
LEFT JOIN "OneDayReferral" ON "OneDayReferral"."refereeId" = earners."userId"
|
||||||
WHERE "OneDayReferral".created_at >= date_trunc('day', now() AT TIME ZONE 'America/Chicago' - interval '1 day')
|
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
|
GROUP BY earners."userId", earners."foreverReferrerId", earners.proportion, earners.rank
|
||||||
ORDER BY rank ASC`
|
ORDER BY rank ASC`
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user