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`