Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new referral scheme #1255

Merged
merged 5 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/resolvers/rewards.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ export default {
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
},
total: async (parent, args, { models }) => {
if (!parent.total) {
return 0
}
return parent.total
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was throwing errors in development.

}
},
Mutation: {
Expand Down
64 changes: 64 additions & 0 deletions api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,68 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
return client
}

function oneDayReferral (request, { me }) {
if (!me) return
const refHeader = request.headers['x-stacker-news-referrer']
if (!refHeader) return

const referrers = refHeader.split('; ').filter(Boolean)
for (const referrer of referrers) {
let prismaPromise, getData

if (referrer.startsWith('item-')) {
prismaPromise = models.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
getData = item => ({
referrerId: item.userId,
refereeId: parseInt(me.id),
type: 'ITEM',
typeId: String(item.id)
})
} else if (referrer.startsWith('profile-')) {
prismaPromise = models.user.findUnique({ where: { name: referrer.slice(8) } })
getData = user => ({
referrerId: user.id,
refereeId: parseInt(me.id),
type: 'PROFILE',
typeId: String(user.id)
})
} else if (referrer.startsWith('territory-')) {
prismaPromise = models.sub.findUnique({ where: { name: referrer.slice(10) } })
getData = sub => ({
referrerId: sub.userId,
refereeId: parseInt(me.id),
type: 'TERRITORY',
typeId: sub.name
})
} else if (referrer.startsWith('comment-')) {
prismaPromise = models.item.findUnique({ where: { id: parseInt(referrer.slice(8)) } })
getData = item => ({
referrerId: item.userId,
refereeId: parseInt(me.id),
type: 'COMMENT',
typeId: String(item.id)
})
} else {
prismaPromise = models.user.findUnique({ where: { name: referrer } })
getData = user => ({
referrerId: user.id,
refereeId: parseInt(me.id),
type: 'REFERRAL',
typeId: String(user.id)
})
}

prismaPromise?.then(ref => {
if (ref && getData) {
const data = getData(ref)
// can't refer yourself
if (data.refereeId === data.referrerId) return
models.oneDayReferral.create({ data }).catch(console.error)
}
}).catch(console.error)
}
}

/**
* Takes a query and variables and returns a getServerSideProps function
*
Expand Down Expand Up @@ -124,6 +186,8 @@ export function getGetServerSideProps (
}
}

oneDayReferral(req, { me })

return {
props: {
...props,
Expand Down
80 changes: 67 additions & 13 deletions middleware.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,77 @@
import { NextResponse } from 'next/server'
import { NextResponse, URLPattern } from 'next/server'

const referrerRegex = /(\/.*)?\/r\/([\w_]+)/
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+)' })
const profilePattern = new URLPattern({ pathname: '/:name([\\w_]+){/:type(\\w+)}?' })
huumn marked this conversation as resolved.
Show resolved Hide resolved
const territoryPattern = new URLPattern({ pathname: '/~:name([\\w_]+){/*}?' })

Copy link
Member Author

@huumn huumn Jul 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are waayyyyyyyyyy better than using regex for urls.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhh, interesting!

I wish we could use this in the browser, too, but it's not supported by every major browser yet according to MDN:

2024-07-07-043155_780x338_scrot

It's also not implemented in node yet, see nodejs/node#40844.

So cool that next/server has this implemented!

// key for /r/... link referrers
const SN_REFERRER = 'sn_referrer'
// we use this to hold /r/... referrers through the redirect
const SN_REFERRER_NONCE = 'sn_referrer_nonce'

// 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
function referrerMiddleware (request) {
const m = referrerRegex.exec(request.nextUrl.pathname)
if (referrerPattern.test(request.url)) {
const { pathname, referrer } = referrerPattern.exec(request.url).pathname.groups

const url = new URL(m[1] || '/', request.url)
url.search = request.nextUrl.search
url.hash = request.nextUrl.hash
const url = new URL(pathname || '/', request.url)
url.search = request.nextUrl.search
url.hash = request.nextUrl.hash

const resp = NextResponse.redirect(url)
resp.cookies.set('sn_referrer', m[2])
return resp
const response = NextResponse.redirect(url)
// explicit referrers are set for a day and can only be overriden by other explicit
// 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 })
// 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
// without this the /r/<referrer> would be lost on redirect
response.cookies.set(SN_REFERRER_NONCE, referrer, { maxAge: 1 })
return response
}

let contentReferrer
if (itemPattern.test(request.url)) {
if (request.nextUrl.searchParams.has('commentId')) {
contentReferrer = `comment-${request.nextUrl.searchParams.get('commentId')}`
} else {
const { 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
const requestHeaders = new Headers(request.headers)
const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
if (referrers.length) {
requestHeaders.set('x-stacker-news-referrer', referrers.join('; '))
}

const response = NextResponse.next({
request: {
headers: requestHeaders
}
})

// if we don't already have an explicit referrer, give them the content referrer as one
if (!request.cookies.has(SN_REFERRER) && contentReferrer) {
response.cookies.set(SN_REFERRER, contentReferrer, { maxAge: 60 * 60 * 24 })
}

return response
}

export function middleware (request) {
let resp = NextResponse.next()
if (referrerRegex.test(request.nextUrl.pathname)) {
resp = referrerMiddleware(request)
}
const resp = referrerMiddleware(request)

const isDev = process.env.NODE_ENV === 'development'

Expand Down
26 changes: 22 additions & 4 deletions pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ function getEventCallbacks () {
}
}

async function getReferrerId (referrer) {
try {
if (referrer.startsWith('item-')) {
return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } }))?.userId
} else if (referrer.startsWith('profile-')) {
return (await prisma.user.findUnique({ where: { name: referrer.slice(8) } }))?.id
} else if (referrer.startsWith('territory-')) {
return (await prisma.sub.findUnique({ where: { name: referrer.slice(10) } }))?.userId
} else if (referrer.startsWith('comment-')) {
return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(8)) } }))?.userId
} else {
return (await prisma.user.findUnique({ where: { name: referrer } }))?.id
}
} catch (error) {
console.error('error getting referrer id', error)
}
}

/** @returns {Partial<import('next-auth').CallbacksOptions>} */
function getCallbacks (req) {
return {
Expand All @@ -57,10 +75,10 @@ function getCallbacks (req) {
// 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 referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
if (referrer && referrer.id !== Number(user.id)) {
await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId: referrer.id } })
notifyReferral(referrer.id)
const referrerId = await getReferrerId(req.cookies.sn_referrer)
if (referrerId && referrerId !== parseInt(user?.id)) {
await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } })
notifyReferral(referrerId)
}
}
}
Expand Down
33 changes: 33 additions & 0 deletions prisma/migrations/20240706000412_one_day_referrals/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- CreateEnum
CREATE TYPE "OneDayReferralType" AS ENUM ('REFERRAL', 'ITEM', 'COMMENT', 'PROFILE', 'TERRITORY');
huumn marked this conversation as resolved.
Show resolved Hide resolved

-- CreateTable
CREATE TABLE "OneDayReferral" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"referrerId" INTEGER NOT NULL,
"refereeId" INTEGER NOT NULL,
"type" "OneDayReferralType" NOT NULL,
"typeId" TEXT NOT NULL,

CONSTRAINT "OneDayReferral_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "OneDayReferral_created_at_idx" ON "OneDayReferral"("created_at");

-- CreateIndex
CREATE INDEX "OneDayReferral_referrerId_idx" ON "OneDayReferral"("referrerId");

-- CreateIndex
CREATE INDEX "OneDayReferral_refereeId_idx" ON "OneDayReferral"("refereeId");

-- CreateIndex
CREATE INDEX "OneDayReferral_type_typeId_idx" ON "OneDayReferral"("type", "typeId");

-- AddForeignKey
ALTER TABLE "OneDayReferral" ADD CONSTRAINT "OneDayReferral_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "OneDayReferral" ADD CONSTRAINT "OneDayReferral_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
27 changes: 27 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,40 @@ model User {
Reminder Reminder[]
PollBlindVote PollBlindVote[]
ItemUserAgg ItemUserAgg[]
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")

@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@@index([inviteId], map: "users.inviteId_index")
@@map("users")
}

enum OneDayReferralType {
REFERRAL
ITEM
COMMENT
PROFILE
TERRITORY
}

model OneDayReferral {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
referrerId Int
refereeId Int
referrer User @relation("OneDayReferral_referrer", fields: [referrerId], references: [id], onDelete: Cascade)
referee User @relation("OneDayReferral_referrees", fields: [refereeId], references: [id], onDelete: Cascade)
type OneDayReferralType
typeId String

@@index([createdAt])
@@index([referrerId])
@@index([refereeId])
@@index([type, typeId])
}

enum WalletType {
LIGHTNING_ADDRESS
LND
Expand Down
Loading