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 (
-
- someone joined SN because of you
- {timeSince(new Date(n.sortTime))}
-
+ <>
+
+ someone joined SN because {referralSource}
+ {timeSince(new Date(n.sortTime))}
+
+ {n.source?.__typename === 'Item' && }
+ >
)
}
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//r/ 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} */
@@ -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`