Compare commits
25 Commits
0d93c92e30
...
a669ec832b
Author | SHA1 | Date | |
---|---|---|---|
|
a669ec832b | ||
|
7dccd383c3 | ||
|
271563efbd | ||
|
ada230597d | ||
|
08501583df | ||
|
74d99e9b74 | ||
|
71caa6d0fe | ||
|
4f17615291 | ||
|
0e4b467b3c | ||
|
9905e6eafe | ||
|
fc6cbba40c | ||
|
60f628e77e | ||
|
63704a5f0f | ||
|
964cdc1d61 | ||
|
e31f8e9c69 | ||
|
b7dfef41c0 | ||
|
344c23ed5c | ||
|
b71398a06c | ||
|
1a52ff7784 | ||
|
a3762f70b0 | ||
|
c492618d31 | ||
|
4be7f12119 | ||
|
b672d015e2 | ||
|
53f6c34ee7 | ||
|
54e7793668 |
@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
|
||||
- Stacker News makes internet communities that pay you Bitcoin
|
||||
- Stacker News is trying to fix online communities with economics
|
||||
- What You See is What We Ship (look ma, I invented an initialism)
|
||||
- 100% FOSS
|
||||
- We pay bitcoin for PRs, issues, documentation, code reviews and more
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
@ -48,9 +49,9 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
||||
let itemAct
|
||||
if (invoice) {
|
||||
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
|
||||
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
|
||||
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } })
|
||||
} else if (actId) {
|
||||
itemAct = await tx.itemAct.findUnique({ where: { id: actId } })
|
||||
itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } })
|
||||
} else {
|
||||
throw new Error('No invoice or actId')
|
||||
}
|
||||
@ -60,19 +61,34 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
||||
|
||||
// denormalize downzaps
|
||||
await tx.$executeRaw`
|
||||
WITH zapper AS (
|
||||
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
|
||||
), zap AS (
|
||||
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
|
||||
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
||||
ON CONFLICT ("itemId", "userId") DO UPDATE
|
||||
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
|
||||
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
||||
)
|
||||
UPDATE "Item"
|
||||
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats)
|
||||
FROM zap, zapper
|
||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
|
||||
WITH territory AS (
|
||||
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
|
||||
FROM "Item" i
|
||||
LEFT JOIN "Item" r ON r.id = i."rootId"
|
||||
WHERE i.id = ${itemAct.itemId}::INTEGER
|
||||
), zapper AS (
|
||||
SELECT
|
||||
COALESCE(${itemAct.item.parentId
|
||||
? Prisma.sql`"zapCommentTrust"`
|
||||
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
|
||||
COALESCE(${itemAct.item.parentId
|
||||
? Prisma.sql`"subZapCommentTrust"`
|
||||
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
|
||||
FROM territory
|
||||
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
|
||||
AND ust."userId" = ${itemAct.userId}::INTEGER
|
||||
), zap AS (
|
||||
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
|
||||
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
||||
ON CONFLICT ("itemId", "userId") DO UPDATE
|
||||
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
|
||||
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
||||
)
|
||||
UPDATE "Item"
|
||||
SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats,
|
||||
"subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats
|
||||
FROM zap, zapper
|
||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
|
||||
}
|
||||
|
||||
export async function onFail ({ invoice }, { tx }) {
|
||||
|
@ -252,15 +252,18 @@ export async function onPaid ({ invoice, id }, context) {
|
||||
JOIN users ON "Item"."userId" = users.id
|
||||
WHERE "Item".id = ${item.id}::INTEGER
|
||||
), ancestors AS (
|
||||
SELECT "Item".*
|
||||
FROM "Item", comment
|
||||
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
|
||||
ORDER BY "Item".id
|
||||
), updated_ancestors AS (
|
||||
UPDATE "Item"
|
||||
SET ncomments = "Item".ncomments + 1,
|
||||
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
|
||||
"weightedComments" = "Item"."weightedComments" +
|
||||
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END,
|
||||
"nDirectComments" = "Item"."nDirectComments" +
|
||||
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
|
||||
FROM comment
|
||||
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
|
||||
FROM comment, ancestors
|
||||
WHERE "Item".id = ancestors.id
|
||||
RETURNING "Item".*
|
||||
)
|
||||
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
|
||||
|
27
api/paidAction/lib/territory.js
Normal file
27
api/paidAction/lib/territory.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { USER_ID } from '@/lib/constants'
|
||||
|
||||
export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek]
|
||||
|
||||
export function initialTrust ({ name, userId }) {
|
||||
const results = GLOBAL_SEEDS.map(id => ({
|
||||
subName: name,
|
||||
userId: id,
|
||||
zapPostTrust: 1,
|
||||
subZapPostTrust: 1,
|
||||
zapCommentTrust: 1,
|
||||
subZapCommentTrust: 1
|
||||
}))
|
||||
|
||||
if (!GLOBAL_SEEDS.includes(userId)) {
|
||||
results.push({
|
||||
subName: name,
|
||||
userId,
|
||||
zapPostTrust: 0,
|
||||
subZapPostTrust: 1,
|
||||
zapCommentTrust: 0,
|
||||
subZapCommentTrust: 1
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
import { nextBilling } from '@/lib/territory'
|
||||
import { initialTrust } from './lib/territory'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
@ -20,7 +21,7 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
||||
const billedLastAt = new Date()
|
||||
const billPaidUntil = nextBilling(billedLastAt, billingType)
|
||||
|
||||
return await tx.sub.create({
|
||||
const sub = await tx.sub.create({
|
||||
data: {
|
||||
...data,
|
||||
billedLastAt,
|
||||
@ -42,6 +43,12 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await tx.userSubTrust.createMany({
|
||||
data: initialTrust({ name: sub.name, userId: sub.userId })
|
||||
})
|
||||
|
||||
return sub
|
||||
}
|
||||
|
||||
export async function describe ({ name }) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
import { nextBilling } from '@/lib/territory'
|
||||
import { initialTrust } from './lib/territory'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
@ -65,7 +66,7 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
||||
}
|
||||
})
|
||||
|
||||
return await tx.sub.update({
|
||||
const updatedSub = await tx.sub.update({
|
||||
data,
|
||||
// optimistic concurrency control
|
||||
// make sure none of the relevant fields have changed since we fetched the sub
|
||||
@ -76,6 +77,12 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await tx.userSubTrust.createMany({
|
||||
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
|
||||
})
|
||||
|
||||
return updatedSub
|
||||
}
|
||||
|
||||
export async function describe ({ name }, context) {
|
||||
|
@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { notifyZapped } from '@/lib/webPush'
|
||||
import { getInvoiceableWallets } from '@/wallets/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export const anonable = true
|
||||
|
||||
@ -149,8 +150,22 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
||||
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
|
||||
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
||||
await tx.$queryRaw`
|
||||
WITH zapper AS (
|
||||
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
|
||||
WITH territory AS (
|
||||
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
|
||||
FROM "Item" i
|
||||
LEFT JOIN "Item" r ON r.id = i."rootId"
|
||||
WHERE i.id = ${itemAct.itemId}::INTEGER
|
||||
), zapper AS (
|
||||
SELECT
|
||||
COALESCE(${itemAct.item.parentId
|
||||
? Prisma.sql`"zapCommentTrust"`
|
||||
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
|
||||
COALESCE(${itemAct.item.parentId
|
||||
? Prisma.sql`"subZapCommentTrust"`
|
||||
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
|
||||
FROM territory
|
||||
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
|
||||
AND ust."userId" = ${itemAct.userId}::INTEGER
|
||||
), zap AS (
|
||||
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
|
||||
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
||||
@ -158,17 +173,30 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
||||
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
|
||||
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
|
||||
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
||||
), item_zapped AS (
|
||||
UPDATE "Item"
|
||||
SET
|
||||
"weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats,
|
||||
"subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats,
|
||||
upvotes = upvotes + zap.first_vote,
|
||||
msats = "Item".msats + ${msats}::BIGINT,
|
||||
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
|
||||
"lastZapAt" = now()
|
||||
FROM zap, zapper
|
||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER
|
||||
RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote"
|
||||
), ancestors AS (
|
||||
SELECT "Item".*
|
||||
FROM "Item", item_zapped
|
||||
WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id
|
||||
ORDER BY "Item".id
|
||||
)
|
||||
UPDATE "Item"
|
||||
SET
|
||||
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
|
||||
upvotes = upvotes + zap.first_vote,
|
||||
msats = "Item".msats + ${msats}::BIGINT,
|
||||
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
|
||||
"lastZapAt" = now()
|
||||
FROM zap, zapper
|
||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER
|
||||
RETURNING "Item".*`
|
||||
SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote",
|
||||
"commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
|
||||
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
|
||||
FROM item_zapped, ancestors
|
||||
WHERE "Item".id = ancestors.id`
|
||||
|
||||
// record potential bounty payment
|
||||
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
|
||||
@ -188,17 +216,6 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
||||
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
|
||||
FROM bounty
|
||||
WHERE "Item".id = bounty.id AND bounty.paid`
|
||||
|
||||
// update commentMsats on ancestors
|
||||
await tx.$executeRaw`
|
||||
WITH zapped AS (
|
||||
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
|
||||
)
|
||||
UPDATE "Item"
|
||||
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
|
||||
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
|
||||
FROM zapped
|
||||
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
|
||||
}
|
||||
|
||||
export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {
|
||||
|
@ -39,16 +39,12 @@ function commentsOrderByClause (me, models, sort) {
|
||||
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
|
||||
}
|
||||
|
||||
if (me && sort === 'hot') {
|
||||
if (sort === 'hot') {
|
||||
return `ORDER BY ${sharedSorts},
|
||||
"personal_hot_score" DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
"hotScore" DESC NULLS LAST,
|
||||
"Item".msats DESC, "Item".id DESC`
|
||||
} else {
|
||||
if (sort === 'top') {
|
||||
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
} else {
|
||||
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
}
|
||||
return `ORDER BY ${sharedSorts}, "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,14 +134,14 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
|
||||
}, ...subArr))?.[0] || null
|
||||
}
|
||||
|
||||
const orderByClause = (by, me, models, type) => {
|
||||
const orderByClause = (by, me, models, type, sub) => {
|
||||
switch (by) {
|
||||
case 'comments':
|
||||
return 'ORDER BY "Item".ncomments DESC'
|
||||
case 'sats':
|
||||
return 'ORDER BY "Item".msats DESC'
|
||||
case 'zaprank':
|
||||
return topOrderByWeightedSats(me, models)
|
||||
return topOrderByWeightedSats(me, models, sub)
|
||||
case 'boost':
|
||||
return 'ORDER BY "Item".boost DESC'
|
||||
case 'random':
|
||||
@ -155,22 +151,8 @@ const orderByClause = (by, me, models, type) => {
|
||||
}
|
||||
}
|
||||
|
||||
export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) {
|
||||
return `((CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
|
||||
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
|
||||
ELSE
|
||||
"Item"."weightedVotes" - "Item"."weightedDownVotes"
|
||||
END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0})`
|
||||
}
|
||||
|
||||
export function joinZapRankPersonalView (me, models) {
|
||||
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${GLOBAL_SEED} `
|
||||
|
||||
if (me) {
|
||||
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${me.id} `
|
||||
}
|
||||
|
||||
return join
|
||||
export function joinHotScoreView (me, models) {
|
||||
return ' JOIN hot_score_view g ON g.id = "Item".id '
|
||||
}
|
||||
|
||||
// this grabs all the stuff we need to display the item list and only
|
||||
@ -475,10 +457,10 @@ export default {
|
||||
await filterClause(me, models, type),
|
||||
by === 'boost' && '"Item".boost > 0',
|
||||
muteClause(me))}
|
||||
${orderByClause(by || 'zaprank', me, models, type)}
|
||||
${orderByClause(by || 'zaprank', me, models, type, sub)}
|
||||
OFFSET $3
|
||||
LIMIT $4`,
|
||||
orderBy: orderByClause(by || 'zaprank', me, models, type)
|
||||
orderBy: orderByClause(by || 'zaprank', me, models, type, sub)
|
||||
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
case 'random':
|
||||
@ -571,10 +553,10 @@ export default {
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
|
||||
${SELECT}, g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore"
|
||||
FROM "Item"
|
||||
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
||||
${joinZapRankPersonalView(me, models)}
|
||||
${joinHotScoreView(me, models)}
|
||||
${whereClause(
|
||||
// in home (sub undefined), filter out global pinned items since we inject them later
|
||||
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
|
||||
@ -587,40 +569,11 @@ export default {
|
||||
await filterClause(me, models, type),
|
||||
subClause(sub, 3, 'Item', me, showNsfw),
|
||||
muteClause(me))}
|
||||
ORDER BY rank DESC
|
||||
ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC
|
||||
OFFSET $1
|
||||
LIMIT $2`,
|
||||
orderBy: 'ORDER BY rank DESC'
|
||||
orderBy: `ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC`
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
|
||||
// XXX this is mostly for subs that are really empty
|
||||
if (items.length < limit) {
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
||||
${whereClause(
|
||||
subClause(sub, 3, 'Item', me, showNsfw),
|
||||
muteClause(me),
|
||||
// in home (sub undefined), filter out global pinned items since we inject them later
|
||||
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."parentId" IS NULL',
|
||||
'"Item".bio = false',
|
||||
ad ? `"Item".id <> ${ad.id}` : '',
|
||||
activeOrMine(me),
|
||||
await filterClause(me, models, type))}
|
||||
ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
|
||||
OFFSET $1
|
||||
LIMIT $2`,
|
||||
orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
@ -1574,6 +1527,9 @@ export const SELECT =
|
||||
`SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
|
||||
ltree2text("Item"."path") AS "path"`
|
||||
|
||||
function topOrderByWeightedSats (me, models) {
|
||||
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
|
||||
function topOrderByWeightedSats (me, models, sub) {
|
||||
if (sub) {
|
||||
return 'ORDER BY "Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" DESC, "Item".msats DESC, "Item".id DESC'
|
||||
}
|
||||
return 'ORDER BY "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC, "Item".msats DESC, "Item".id DESC'
|
||||
}
|
||||
|
@ -174,7 +174,6 @@ export default {
|
||||
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let sitems = null
|
||||
let termQueries = []
|
||||
|
||||
// short circuit: return empty result if either:
|
||||
// 1. no query provided, or
|
||||
@ -186,56 +185,116 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const whatArr = []
|
||||
// build query in parts:
|
||||
// filters: determine the universe of potential search candidates
|
||||
// termQueries: queries related to the actual search terms
|
||||
// functions: rank modifiers to boost by recency or popularity
|
||||
const filters = []
|
||||
const termQueries = []
|
||||
const functions = []
|
||||
|
||||
// filters for item types
|
||||
switch (what) {
|
||||
case 'posts':
|
||||
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
|
||||
case 'posts': // posts only
|
||||
filters.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
|
||||
break
|
||||
case 'comments':
|
||||
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
|
||||
case 'comments': // comments only
|
||||
filters.push({ bool: { must: { exists: { field: 'parentId' } } } })
|
||||
break
|
||||
case 'bookmarks':
|
||||
if (me?.id) {
|
||||
whatArr.push({ match: { bookmarkedBy: me?.id } })
|
||||
filters.push({ match: { bookmarkedBy: me?.id } })
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// filter for active posts
|
||||
filters.push(
|
||||
me
|
||||
? {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } },
|
||||
{ match: { userId: me.id } }
|
||||
]
|
||||
}
|
||||
}
|
||||
: {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// filter for time range
|
||||
const whenRange = when === 'custom'
|
||||
? {
|
||||
gte: whenFrom,
|
||||
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
|
||||
}
|
||||
: {
|
||||
lte: decodedCursor.time,
|
||||
gte: whenToFrom(when)
|
||||
}
|
||||
filters.push({ range: { createdAt: whenRange } })
|
||||
|
||||
// filter for non negative wvotes
|
||||
filters.push({ range: { wvotes: { gte: 0 } } })
|
||||
|
||||
// decompose the search terms
|
||||
const { query: _query, quotes, nym, url, territory } = queryParts(q)
|
||||
let query = _query
|
||||
|
||||
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
|
||||
const query = _query
|
||||
|
||||
// if search contains a url term, modify the query text
|
||||
if (url) {
|
||||
const isFQDN = url.startsWith('url:www.')
|
||||
const domain = isFQDN ? url.slice(8) : url.slice(4)
|
||||
const fqdn = `www.${domain}`
|
||||
query = (isUrlSearch) ? `${domain} ${fqdn}` : `${query.trim()} ${domain}`
|
||||
}
|
||||
|
||||
if (nym) {
|
||||
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
|
||||
}
|
||||
|
||||
if (territory) {
|
||||
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
|
||||
}
|
||||
|
||||
termQueries.push({
|
||||
// all terms are matched in fields
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'best_fields',
|
||||
fields: ['title^100', 'text'],
|
||||
minimum_should_match: (isUrlSearch) ? 1 : '100%',
|
||||
boost: 1000
|
||||
const uri = url.slice(4)
|
||||
let uriObj
|
||||
try {
|
||||
uriObj = new URL(uri)
|
||||
} catch {
|
||||
try {
|
||||
uriObj = new URL(`https://${uri}`)
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
if (uriObj) {
|
||||
termQueries.push({
|
||||
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
|
||||
})
|
||||
termQueries.push({
|
||||
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// if nym, items must contain nym
|
||||
if (nym) {
|
||||
filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
|
||||
}
|
||||
|
||||
// if territory, item must be from territory
|
||||
if (territory) {
|
||||
filters.push({ match: { 'sub.name': territory.slice(1) } })
|
||||
}
|
||||
|
||||
// if quoted phrases, items must contain entire phrase
|
||||
for (const quote of quotes) {
|
||||
whatArr.push({
|
||||
termQueries.push({
|
||||
multi_match: {
|
||||
query: quote,
|
||||
type: 'phrase',
|
||||
fields: ['title', 'text']
|
||||
}
|
||||
})
|
||||
|
||||
// force the search to include the quoted phrase
|
||||
filters.push({
|
||||
multi_match: {
|
||||
query: quote,
|
||||
type: 'phrase',
|
||||
@ -244,84 +303,104 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
// if we search for an exact string only, everything must match
|
||||
// so score purely on sort field
|
||||
let boostMode = query ? 'multiply' : 'replace'
|
||||
let sortField
|
||||
let sortMod = 'log1p'
|
||||
// functions for boosting search rank by recency or popularity
|
||||
switch (sort) {
|
||||
case 'comments':
|
||||
sortField = 'ncomments'
|
||||
sortMod = 'square'
|
||||
functions.push({
|
||||
field_value_factor: {
|
||||
field: 'ncomments',
|
||||
modifier: 'log1p'
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'sats':
|
||||
sortField = 'sats'
|
||||
functions.push({
|
||||
field_value_factor: {
|
||||
field: 'sats',
|
||||
modifier: 'log1p'
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'recent':
|
||||
sortField = 'createdAt'
|
||||
sortMod = 'square'
|
||||
boostMode = 'replace'
|
||||
functions.push({
|
||||
gauss: {
|
||||
createdAt: {
|
||||
origin: 'now',
|
||||
scale: '7d',
|
||||
decay: 0.5
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'zaprank':
|
||||
functions.push({
|
||||
field_value_factor: {
|
||||
field: 'wvotes',
|
||||
modifier: 'log1p'
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
sortField = 'wvotes'
|
||||
sortMod = 'none'
|
||||
break
|
||||
}
|
||||
|
||||
const functions = [
|
||||
{
|
||||
field_value_factor: {
|
||||
field: sortField,
|
||||
modifier: sortMod,
|
||||
factor: 1.2
|
||||
}
|
||||
let osQuery = {
|
||||
function_score: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: filters,
|
||||
should: termQueries,
|
||||
minimum_should_match: termQueries.length > 0 ? 1 : 0
|
||||
}
|
||||
},
|
||||
functions,
|
||||
score_mode: 'multiply',
|
||||
boost_mode: 'multiply'
|
||||
}
|
||||
]
|
||||
|
||||
if (sort === 'recent' && !isUrlSearch) {
|
||||
// prioritize exact matches
|
||||
termQueries.push({
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'phrase',
|
||||
fields: ['title^100', 'text'],
|
||||
boost: 1000
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// allow fuzzy matching with partial matches
|
||||
termQueries.push({
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^100', 'text'],
|
||||
fuzziness: 'AUTO',
|
||||
prefix_length: 3,
|
||||
minimum_should_match: (isUrlSearch) ? 1 : '60%'
|
||||
}
|
||||
})
|
||||
functions.push({
|
||||
// small bias toward posts with comments
|
||||
field_value_factor: {
|
||||
field: 'ncomments',
|
||||
modifier: 'ln1p',
|
||||
factor: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
// small bias toward recent posts
|
||||
field_value_factor: {
|
||||
field: 'createdAt',
|
||||
modifier: 'log1p',
|
||||
factor: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// query for search terms
|
||||
if (query.length) {
|
||||
// if we have a model id and we aren't sort by recent, use neural search
|
||||
if (process.env.OPENSEARCH_MODEL_ID && sort !== 'recent') {
|
||||
termQueries = {
|
||||
// keyword based subquery, to be used on its own or in conjunction with a neural
|
||||
// search
|
||||
const subquery = [
|
||||
{
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'best_fields',
|
||||
fields: ['title^10', 'text'],
|
||||
fuzziness: 'AUTO',
|
||||
minimum_should_match: 1
|
||||
}
|
||||
},
|
||||
// all match matches higher
|
||||
{
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'best_fields',
|
||||
fields: ['title^10', 'text'],
|
||||
minimum_should_match: '100%',
|
||||
boost: 100
|
||||
}
|
||||
},
|
||||
// phrase match matches higher
|
||||
{
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'phrase',
|
||||
fields: ['title^10', 'text'],
|
||||
boost: 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
osQuery.function_score.query.bool.should = [...termQueries, ...subquery]
|
||||
osQuery.function_score.query.bool.minimum_should_match = 1
|
||||
|
||||
// use hybrid neural search if model id is available, otherwise use only
|
||||
// keyword search
|
||||
if (process.env.OPENSEARCH_MODEL_ID) {
|
||||
osQuery = {
|
||||
hybrid: {
|
||||
queries: [
|
||||
{
|
||||
@ -345,32 +424,18 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
filter: filters,
|
||||
minimum_should_match: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
should: termQueries
|
||||
}
|
||||
}
|
||||
osQuery
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
termQueries = []
|
||||
}
|
||||
|
||||
const whenRange = when === 'custom'
|
||||
? {
|
||||
gte: whenFrom,
|
||||
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
|
||||
}
|
||||
: {
|
||||
lte: decodedCursor.time,
|
||||
gte: whenToFrom(when)
|
||||
}
|
||||
|
||||
try {
|
||||
sitems = await search.search({
|
||||
index: process.env.OPENSEARCH_INDEX,
|
||||
@ -384,45 +449,7 @@ export default {
|
||||
},
|
||||
from: decodedCursor.offset,
|
||||
body: {
|
||||
query: {
|
||||
function_score: {
|
||||
query: {
|
||||
bool: {
|
||||
must: termQueries,
|
||||
filter: [
|
||||
...whatArr,
|
||||
me
|
||||
? {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } },
|
||||
{ match: { userId: me.id } }
|
||||
]
|
||||
}
|
||||
}
|
||||
: {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
range:
|
||||
{
|
||||
createdAt: whenRange
|
||||
}
|
||||
},
|
||||
{ range: { wvotes: { gte: 0 } } }
|
||||
]
|
||||
}
|
||||
},
|
||||
functions,
|
||||
boost_mode: boostMode
|
||||
}
|
||||
},
|
||||
query: osQuery,
|
||||
highlight: {
|
||||
fields: {
|
||||
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
||||
@ -458,7 +485,7 @@ export default {
|
||||
${SELECT}, rank
|
||||
FROM "Item"
|
||||
JOIN r ON "Item".id = r.id`,
|
||||
orderBy: 'ORDER BY rank ASC'
|
||||
orderBy: 'ORDER BY rank ASC, msats DESC'
|
||||
})).map((item, i) => {
|
||||
const e = sitems.body.hits.hits[i]
|
||||
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
|
||||
|
@ -189,3 +189,5 @@ Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
|
||||
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
|
||||
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
|
||||
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
|
||||
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
|
||||
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
|
||||
|
|
@ -8,40 +8,31 @@ import { useQuery } from '@apollo/client'
|
||||
import { UserListRow } from '@/components/user-list'
|
||||
import Link from 'next/link'
|
||||
import AddIcon from '@/svgs/add-fill.svg'
|
||||
import { MultiAuthErrorBanner } from '@/components/banners'
|
||||
|
||||
const AccountContext = createContext()
|
||||
|
||||
const CHECK_ERRORS_INTERVAL_MS = 5_000
|
||||
|
||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
|
||||
const maybeSecureCookie = cookie => {
|
||||
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
|
||||
}
|
||||
|
||||
export const AccountProvider = ({ children }) => {
|
||||
const { me } = useMe()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [meAnon, setMeAnon] = useState(true)
|
||||
const [errors, setErrors] = useState([])
|
||||
|
||||
const updateAccountsFromCookie = useCallback(() => {
|
||||
try {
|
||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||
const accounts = multiAuthCookie
|
||||
? JSON.parse(b64Decode(multiAuthCookie))
|
||||
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
|
||||
setAccounts(accounts)
|
||||
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
|
||||
// this is the case for sessions that existed before we deployed account switching
|
||||
if (!multiAuthCookie && !!me) {
|
||||
document.cookie = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('error parsing cookies:', err)
|
||||
}
|
||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||
const accounts = multiAuthCookie
|
||||
? JSON.parse(b64Decode(multiAuthCookie))
|
||||
: []
|
||||
setAccounts(accounts)
|
||||
}, [])
|
||||
|
||||
useEffect(updateAccountsFromCookie, [])
|
||||
|
||||
const addAccount = useCallback(user => {
|
||||
setAccounts(accounts => [...accounts, user])
|
||||
}, [])
|
||||
@ -50,7 +41,7 @@ export const AccountProvider = ({ children }) => {
|
||||
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
|
||||
}, [])
|
||||
|
||||
const multiAuthSignout = useCallback(async () => {
|
||||
const nextAccount = useCallback(async () => {
|
||||
const { status } = await fetch('/api/next-account', { credentials: 'include' })
|
||||
// if status is 302, this means the server was able to switch us to the next available account
|
||||
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
|
||||
@ -59,15 +50,43 @@ export const AccountProvider = ({ children }) => {
|
||||
return switchSuccess
|
||||
}, [updateAccountsFromCookie])
|
||||
|
||||
useEffect(() => {
|
||||
if (SSR) return
|
||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
||||
setMeAnon(multiAuthUserIdCookie === 'anonymous')
|
||||
const checkErrors = useCallback(() => {
|
||||
const {
|
||||
multi_auth: multiAuthCookie,
|
||||
'multi_auth.user-id': multiAuthUserIdCookie
|
||||
} = cookie.parse(document.cookie)
|
||||
|
||||
const errors = []
|
||||
|
||||
if (!multiAuthCookie) errors.push('multi_auth cookie not found')
|
||||
if (!multiAuthUserIdCookie) errors.push('multi_auth.user-id cookie not found')
|
||||
|
||||
setErrors(errors)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (SSR) return
|
||||
|
||||
updateAccountsFromCookie()
|
||||
|
||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
||||
setMeAnon(multiAuthUserIdCookie === 'anonymous')
|
||||
|
||||
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [updateAccountsFromCookie, checkErrors])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout }),
|
||||
[accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout])
|
||||
() => ({
|
||||
accounts,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
meAnon,
|
||||
setMeAnon,
|
||||
nextAccount,
|
||||
multiAuthErrors: errors
|
||||
}),
|
||||
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount, errors])
|
||||
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
}
|
||||
|
||||
@ -129,9 +148,23 @@ const AccountListRow = ({ account, ...props }) => {
|
||||
}
|
||||
|
||||
export default function SwitchAccountList () {
|
||||
const { accounts } = useAccounts()
|
||||
const { accounts, multiAuthErrors } = useAccounts()
|
||||
const router = useRouter()
|
||||
|
||||
const hasError = multiAuthErrors.length > 0
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<>
|
||||
<div className='my-2'>
|
||||
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
||||
<MultiAuthErrorBanner errors={multiAuthErrors} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// can't show hat since the streak is not included in the JWT payload
|
||||
return (
|
||||
<>
|
||||
|
@ -6,6 +6,7 @@ import { useMutation } from '@apollo/client'
|
||||
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
||||
import { useToast } from '@/components/toast'
|
||||
import Link from 'next/link'
|
||||
import AccordianItem from '@/components/accordian-item'
|
||||
|
||||
export function WelcomeBanner ({ Banner }) {
|
||||
const { me } = useMe()
|
||||
@ -123,3 +124,24 @@ export function AuthBanner () {
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiAuthErrorBanner ({ errors }) {
|
||||
return (
|
||||
<Alert className={styles.banner} key='info' variant='danger'>
|
||||
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
|
||||
<AccordianItem
|
||||
className='my-3'
|
||||
header='We have detected the following issues:'
|
||||
headerColor='var(--bs-danger-text-emphasis)'
|
||||
body={
|
||||
<ul>
|
||||
{errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { signIn } from 'next-auth/react'
|
||||
import styles from './login.module.css'
|
||||
import { Form, Input, SubmitButton } from '@/components/form'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Alert from 'react-bootstrap/Alert'
|
||||
import { useRouter } from 'next/router'
|
||||
import { LightningAuthWithExplainer } from './lightning-auth'
|
||||
@ -42,10 +42,10 @@ const authErrorMessages = {
|
||||
OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.',
|
||||
OAuthCreateAccount: 'Could not create OAuth account. Try again or choose a different method.',
|
||||
EmailCreateAccount: 'Could not create Email account. Try again or choose a different method.',
|
||||
Callback: 'Try again or choose a different method.',
|
||||
Callback: 'Could not authenticate. Try again or choose a different method.',
|
||||
OAuthAccountNotLinked: 'This auth method is linked to another account. To link to this account first unlink the other account.',
|
||||
EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.',
|
||||
CredentialsSignin: 'Auth failed. Try again or choose a different method.',
|
||||
CredentialsSignin: 'Could not authenticate. Try again or choose a different method.',
|
||||
default: 'Auth failed. Try again or choose a different method.'
|
||||
}
|
||||
|
||||
@ -53,10 +53,23 @@ export function authErrorMessage (error) {
|
||||
return error && (authErrorMessages[error] ?? authErrorMessages.default)
|
||||
}
|
||||
|
||||
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer }) {
|
||||
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
|
||||
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
|
||||
const router = useRouter()
|
||||
|
||||
// signup/signin awareness cookie
|
||||
useEffect(() => {
|
||||
const cookieOptions = [
|
||||
`signin=${!!signin}`,
|
||||
'path=/',
|
||||
'max-age=' + (signin ? 60 * 60 * 24 : 0), // 24 hours if signin is true, expire the cookie otherwise
|
||||
'SameSite=Lax',
|
||||
process.env.NODE_ENV === 'production' ? 'Secure' : ''
|
||||
].filter(Boolean).join(';')
|
||||
|
||||
document.cookie = cookieOptions
|
||||
}, [signin])
|
||||
|
||||
if (router.query.type === 'lightning') {
|
||||
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
|
||||
}
|
||||
@ -112,6 +125,7 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
|
||||
default:
|
||||
return (
|
||||
<OverlayTrigger
|
||||
key={provider.id}
|
||||
placement='bottom'
|
||||
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
|
||||
trigger={['hover', 'focus']}
|
||||
@ -119,7 +133,6 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
|
||||
<div className='w-100'>
|
||||
<LoginButton
|
||||
className={`mt-2 ${styles.providerButton}`}
|
||||
key={provider.id}
|
||||
type={provider.id.toLowerCase()}
|
||||
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
|
||||
text={`${text || 'Login'} with`}
|
||||
|
@ -223,6 +223,9 @@ export function MeDropdown ({ me, dropNavKey }) {
|
||||
)
|
||||
}
|
||||
|
||||
// this is the width of the 'switch account' button if no width is given
|
||||
const SWITCH_ACCOUNT_BUTTON_WIDTH = '162px'
|
||||
|
||||
export function SignUpButton ({ className = 'py-0', width }) {
|
||||
const router = useRouter()
|
||||
const handleLogin = useCallback(async pathname => await router.push({
|
||||
@ -233,7 +236,8 @@ export function SignUpButton ({ className = 'py-0', width }) {
|
||||
return (
|
||||
<Button
|
||||
className={classNames('align-items-center ps-2 pe-3', className)}
|
||||
style={{ borderWidth: '2px', width: width || '150px' }}
|
||||
// 161px is the width of the 'switch account' button
|
||||
style={{ borderWidth: '2px', width: width || SWITCH_ACCOUNT_BUTTON_WIDTH }}
|
||||
id='signup'
|
||||
onClick={() => handleLogin('/signup')}
|
||||
>
|
||||
@ -257,7 +261,7 @@ export default function LoginButton () {
|
||||
<Button
|
||||
className='align-items-center px-3 py-1'
|
||||
id='login'
|
||||
style={{ borderWidth: '2px', width: '150px' }}
|
||||
style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
|
||||
variant='outline-grey-darkmode'
|
||||
onClick={() => handleLogin('/login')}
|
||||
>
|
||||
@ -269,7 +273,7 @@ export default function LoginButton () {
|
||||
function LogoutObstacle ({ onClose }) {
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const { removeLocalWallets } = useWallets()
|
||||
const { multiAuthSignout } = useAccounts()
|
||||
const { nextAccount } = useAccounts()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
@ -285,9 +289,9 @@ function LogoutObstacle ({ onClose }) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const switchSuccess = await multiAuthSignout()
|
||||
// only signout if multiAuth did not find a next available account
|
||||
if (switchSuccess) {
|
||||
const next = await nextAccount()
|
||||
// only signout if we did not find a next account
|
||||
if (next) {
|
||||
onClose()
|
||||
// reload whatever page we're on to avoid any bugs
|
||||
router.reload()
|
||||
@ -344,7 +348,7 @@ function SwitchAccountButton ({ handleClose }) {
|
||||
<Button
|
||||
className='align-items-center px-3 py-1'
|
||||
variant='outline-grey-darkmode'
|
||||
style={{ borderWidth: '2px', width: '150px' }}
|
||||
style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
|
||||
onClick={() => {
|
||||
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
|
||||
// so we manually close the offcanvas in that case by passing down handleClose here
|
||||
|
@ -72,7 +72,10 @@ export default function PullToRefresh ({ children, className }) {
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<p className={`${styles.pullMessage}`} style={{ top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}>
|
||||
<p
|
||||
className={`${styles.pullMessage}`}
|
||||
style={{ opacity: pullDistance > 0 ? 1 : 0, top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}
|
||||
>
|
||||
{pullMessage}
|
||||
</p>
|
||||
{children}
|
||||
|
@ -36,7 +36,7 @@ export default function Search ({ sub }) {
|
||||
}
|
||||
|
||||
if (values.what === '' || values.what === 'all') delete values.what
|
||||
if (values.sort === '' || values.sort === 'zaprank') delete values.sort
|
||||
if (values.sort === '' || values.sort === 'relevance') delete values.sort
|
||||
if (values.when === '' || values.when === 'forever') delete values.when
|
||||
if (values.when !== 'custom') { delete values.from; delete values.to }
|
||||
if (values.from && !values.to) return
|
||||
@ -50,7 +50,7 @@ export default function Search ({ sub }) {
|
||||
|
||||
const filter = sub !== 'jobs'
|
||||
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all'
|
||||
const sort = router.query.sort || 'zaprank'
|
||||
const sort = router.query.sort || 'relevance'
|
||||
const when = router.query.when || 'forever'
|
||||
const whatItemOptions = useMemo(() => (['all', 'posts', 'comments', me ? 'bookmarks' : undefined, 'stackers'].filter(item => !!item)), [me])
|
||||
|
||||
@ -100,7 +100,7 @@ export default function Search ({ sub }) {
|
||||
name='sort'
|
||||
size='sm'
|
||||
overrideValue={sort}
|
||||
items={['zaprank', 'recent', 'comments', 'sats']}
|
||||
items={['relevance', 'zaprank', 'recent', 'comments', 'sats']}
|
||||
/>
|
||||
for
|
||||
<Select
|
||||
|
@ -94,8 +94,19 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
|
||||
}
|
||||
} else {
|
||||
// we're currently on the home sub
|
||||
// are we in a sub aware route?
|
||||
if (router.pathname.startsWith('/~')) {
|
||||
// if in /top/cowboys, /top/territories, or /top/stackers
|
||||
// and a territory is selected, go to /~sub/top/posts/day
|
||||
if (router.pathname.startsWith('/~/top/cowboys')) {
|
||||
router.push(sub ? `/~${sub}/top/posts/day` : '/top/cowboys')
|
||||
return
|
||||
} else if (router.pathname.startsWith('/~/top/stackers')) {
|
||||
router.push(sub ? `/~${sub}/top/posts/day` : 'top/stackers/day')
|
||||
return
|
||||
} else if (router.pathname.startsWith('/~/top/territories')) {
|
||||
router.push(sub ? `/~${sub}/top/posts/day` : '/top/territories/day')
|
||||
return
|
||||
} else if (router.pathname.startsWith('/~')) {
|
||||
// are we in a sub aware route?
|
||||
// if we are, go to the same path but in the sub
|
||||
asPath = `/~${sub}` + router.asPath
|
||||
} else {
|
||||
|
@ -4,7 +4,7 @@ import Image from 'react-bootstrap/Image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import Nav from 'react-bootstrap/Nav'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Form, Input, SubmitButton } from './form'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import styles from './user-header.module.css'
|
||||
@ -199,8 +199,14 @@ export function NymActionDropdown ({ user, className = 'ms-2' }) {
|
||||
}
|
||||
|
||||
function HeaderNym ({ user, isMe }) {
|
||||
const router = useRouter()
|
||||
const [editting, setEditting] = useState(false)
|
||||
|
||||
// if route changes, reset editting state
|
||||
useEffect(() => {
|
||||
setEditting(false)
|
||||
}, [router.asPath])
|
||||
|
||||
return editting
|
||||
? <NymEdit user={user} setEditting={setEditting} />
|
||||
: <NymView user={user} isMe={isMe} setEditting={setEditting} />
|
||||
|
@ -62,7 +62,11 @@ function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
|
||||
const { deleteLogs } = useWalletLogManager(setLogs)
|
||||
const toaster = useToast()
|
||||
|
||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||
let prompt = 'Do you really want to delete all wallet logs?'
|
||||
if (wallet) {
|
||||
prompt = 'Do you really want to delete all logs of this wallet?'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{prompt}
|
||||
|
@ -163,7 +163,7 @@ services:
|
||||
- "CONNECT=localhost:4566"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.12.0
|
||||
image: opensearchproject/opensearch:2.17.0
|
||||
container_name: opensearch
|
||||
profiles:
|
||||
- search
|
||||
@ -203,7 +203,7 @@ services:
|
||||
'
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
os-dashboard:
|
||||
image: opensearchproject/opensearch-dashboards:2.12.0
|
||||
image: opensearchproject/opensearch-dashboards:2.17.0
|
||||
container_name: os-dashboard
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
@ -687,7 +687,7 @@ services:
|
||||
- sn_lnd
|
||||
- lnd
|
||||
- router_lnd
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME} -f label=ofelia.group=payments
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
161
lib/auth.js
Normal file
161
lib/auth.js
Normal file
@ -0,0 +1,161 @@
|
||||
import { datePivot } from '@/lib/time'
|
||||
import * as cookie from 'cookie'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt'
|
||||
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
||||
|
||||
const userJwtRegexp = /^multi_auth\.\d+$/
|
||||
|
||||
const HTTPS = process.env.NODE_ENV === 'production'
|
||||
const SESSION_COOKIE_NAME = HTTPS ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
|
||||
const cookieOptions = (args) => ({
|
||||
path: '/',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// httpOnly cookies by default
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
// default expiration for next-auth JWTs is in 30 days
|
||||
expires: datePivot(new Date(), { days: 30 }),
|
||||
...args
|
||||
})
|
||||
|
||||
export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
|
||||
const httpOnlyOptions = cookieOptions()
|
||||
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
|
||||
|
||||
// add JWT to **httpOnly** cookie
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, httpOnlyOptions))
|
||||
|
||||
// switch to user we just added
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, jsOptions))
|
||||
|
||||
let newMultiAuth = [{ id, name, photoId }]
|
||||
if (req.cookies.multi_auth) {
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
// make sure we don't add duplicates
|
||||
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
|
||||
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
|
||||
}
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), jsOptions))
|
||||
}
|
||||
|
||||
export function switchSessionCookie (request) {
|
||||
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
|
||||
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const hasCookiePointer = !!request.cookies[cookiePointerName]
|
||||
|
||||
// is there a session?
|
||||
const hasSession = !!request.cookies[SESSION_COOKIE_NAME]
|
||||
|
||||
if (!hasCookiePointer || !hasSession) {
|
||||
// no session or no cookie pointer. do nothing.
|
||||
return request
|
||||
}
|
||||
|
||||
const userId = request.cookies[cookiePointerName]
|
||||
if (userId === 'anonymous') {
|
||||
// user switched to anon. only delete session cookie.
|
||||
delete request.cookies[SESSION_COOKIE_NAME]
|
||||
return request
|
||||
}
|
||||
|
||||
const userJWT = request.cookies[`multi_auth.${userId}`]
|
||||
if (!userJWT) {
|
||||
// no JWT for account switching found
|
||||
return request
|
||||
}
|
||||
|
||||
if (userJWT) {
|
||||
// use JWT found in cookie pointed to by cookie pointer
|
||||
request.cookies[SESSION_COOKIE_NAME] = userJWT
|
||||
return request
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
export function checkMultiAuthCookies (req, res) {
|
||||
if (!req.cookies.multi_auth || !req.cookies['multi_auth.user-id']) {
|
||||
return false
|
||||
}
|
||||
|
||||
const accounts = b64Decode(req.cookies.multi_auth)
|
||||
for (const account of accounts) {
|
||||
if (!req.cookies[`multi_auth.${account.id}`]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function resetMultiAuthCookies (req, res) {
|
||||
const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 })
|
||||
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
|
||||
|
||||
if ('multi_auth' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', '', jsOptions))
|
||||
if ('multi_auth.user-id' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', '', jsOptions))
|
||||
|
||||
for (const key of Object.keys(req.cookies)) {
|
||||
// reset all user JWTs
|
||||
if (userJwtRegexp.test(key)) {
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(key, '', httpOnlyOptions))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshMultiAuthCookies (req, res) {
|
||||
const httpOnlyOptions = cookieOptions()
|
||||
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
|
||||
|
||||
const refreshCookie = (name) => {
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(name, req.cookies[name], jsOptions))
|
||||
}
|
||||
|
||||
const refreshToken = async (token) => {
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
return await encodeJWT({
|
||||
token: await decodeJWT({ token, secret }),
|
||||
secret
|
||||
})
|
||||
}
|
||||
|
||||
const isAnon = req.cookies['multi_auth.user-id'] === 'anonymous'
|
||||
|
||||
for (const [key, value] of Object.entries(req.cookies)) {
|
||||
// only refresh session cookie manually if we switched to anon since else it's already handled by next-auth
|
||||
if (key === SESSION_COOKIE_NAME && !isAnon) continue
|
||||
|
||||
if (!key.startsWith('multi_auth') && key !== SESSION_COOKIE_NAME) continue
|
||||
|
||||
if (userJwtRegexp.test(key) || key === SESSION_COOKIE_NAME) {
|
||||
const oldToken = value
|
||||
const newToken = await refreshToken(oldToken)
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions))
|
||||
continue
|
||||
}
|
||||
|
||||
refreshCookie(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function multiAuthMiddleware (req, res) {
|
||||
if (!req.cookies) {
|
||||
// required to properly access parsed cookies via req.cookies and not unparsed via req.headers.cookie
|
||||
req = new NodeNextRequest(req)
|
||||
}
|
||||
|
||||
const ok = checkMultiAuthCookies(req, res)
|
||||
if (!ok) {
|
||||
resetMultiAuthCookies(req, res)
|
||||
return switchSessionCookie(req)
|
||||
}
|
||||
|
||||
await refreshMultiAuthCookies(req, res)
|
||||
return switchSessionCookie(req)
|
||||
}
|
@ -23,11 +23,11 @@ export function timeSince (timeStamp) {
|
||||
}
|
||||
|
||||
export function datePivot (date,
|
||||
{ years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) {
|
||||
{ years = 0, months = 0, weeks = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) {
|
||||
return new Date(
|
||||
date.getFullYear() + years,
|
||||
date.getMonth() + months,
|
||||
date.getDate() + days,
|
||||
date.getDate() + days + weeks * 7,
|
||||
date.getHours() + hours,
|
||||
date.getMinutes() + minutes,
|
||||
date.getSeconds() + seconds,
|
||||
|
@ -194,6 +194,21 @@ module.exports = withPlausibleProxy()({
|
||||
source: '/top/cowboys/:when',
|
||||
destination: '/top/cowboys',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/~:sub/top/cowboys',
|
||||
destination: '/top/cowboys',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/~:sub/top/stackers/:when*',
|
||||
destination: '/top/stackers/:when*',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/~:sub/top/territories/:when*',
|
||||
destination: '/top/territories/:when*',
|
||||
permanent: true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -8,13 +8,13 @@ import prisma from '@/api/models'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
|
||||
import { datePivot } from '@/lib/time'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { notifyReferral } from '@/lib/webPush'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
import * as cookie from 'cookie'
|
||||
import { multiAuthMiddleware } from '@/pages/api/graphql'
|
||||
import { multiAuthMiddleware, setMultiAuthCookies } from '@/lib/auth'
|
||||
import { BECH32_CHARSET } from '@/lib/constants'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import * as cookie from 'cookie'
|
||||
|
||||
/**
|
||||
* Stores userIds in user table
|
||||
@ -94,6 +94,8 @@ function getCallbacks (req, res) {
|
||||
*/
|
||||
async jwt ({ token, user, account, profile, isNewUser }) {
|
||||
if (user) {
|
||||
// reset signup cookie if any
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('signin', '', { path: '/', expires: 0, maxAge: 0 }))
|
||||
// token won't have an id on it for new logins, we add it
|
||||
// note: token is what's kept in the jwt
|
||||
token.id = Number(user.id)
|
||||
@ -124,8 +126,8 @@ function getCallbacks (req, res) {
|
||||
token.sub = Number(token.id)
|
||||
}
|
||||
|
||||
// add multi_auth cookie for user that just logged in
|
||||
if (user && req && res) {
|
||||
// add multi_auth cookie for user that just logged in
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
const jwt = await encodeJWT({ token, secret })
|
||||
const me = await prisma.user.findUnique({ where: { id: token.id } })
|
||||
@ -144,37 +146,6 @@ function getCallbacks (req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
||||
|
||||
// default expiration for next-auth JWTs is in 1 month
|
||||
const expiresAt = datePivot(new Date(), { months: 1 })
|
||||
const secure = process.env.NODE_ENV === 'production'
|
||||
const cookieOptions = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt
|
||||
}
|
||||
|
||||
// add JWT to **httpOnly** cookie
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
|
||||
|
||||
// switch to user we just added
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, { ...cookieOptions, httpOnly: false }))
|
||||
|
||||
let newMultiAuth = [{ id, name, photoId }]
|
||||
if (req.cookies.multi_auth) {
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
// make sure we don't add duplicates
|
||||
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
|
||||
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
|
||||
}
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
|
||||
}
|
||||
|
||||
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||
const { k1, pubkey } = credentials
|
||||
|
||||
@ -194,7 +165,7 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
|
||||
|
||||
// make following code aware of cookie pointer for account switching
|
||||
req = multiAuthMiddleware(req)
|
||||
req = await multiAuthMiddleware(req, res)
|
||||
// token will be undefined if we're not logged in at all or if we switched to anon
|
||||
const token = await getToken({ req })
|
||||
if (!user) {
|
||||
@ -205,7 +176,8 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||
if (token?.id && !multiAuth) {
|
||||
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
|
||||
} else {
|
||||
// we're not logged in: create new user with that pubkey
|
||||
// create a new user only if we're trying to sign up
|
||||
if (new NodeNextRequest(req).cookies.signin) return null
|
||||
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
|
||||
}
|
||||
}
|
||||
@ -314,6 +286,7 @@ export const getAuthOptions = (req, res) => ({
|
||||
adapter: {
|
||||
...PrismaAdapter(prisma),
|
||||
createUser: data => {
|
||||
if (req.cookies.signin) return null
|
||||
// replace email with email hash in new user payload
|
||||
if (data.email) {
|
||||
const { email } = data
|
||||
@ -754,7 +727,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments “upvotes,” Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments "upvotes," Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -769,7 +742,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you’re not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you're not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -779,7 +752,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn’t clear, comment on the FAQ post and we’ll answer your question.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn't clear, comment on the FAQ post and we'll answer your question.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
ApolloServerPluginLandingPageLocalDefault,
|
||||
ApolloServerPluginLandingPageProductionDefault
|
||||
} from '@apollo/server/plugin/landingPage/default'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import { multiAuthMiddleware } from '@/lib/auth'
|
||||
|
||||
const apolloServer = new ApolloServer({
|
||||
typeDefs,
|
||||
@ -68,7 +68,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
|
||||
session = { user: { ...sessionFields, apiKey: true } }
|
||||
}
|
||||
} else {
|
||||
req = multiAuthMiddleware(req)
|
||||
req = await multiAuthMiddleware(req, res)
|
||||
session = await getServerSession(req, res, getAuthOptions(req))
|
||||
}
|
||||
return {
|
||||
@ -82,49 +82,3 @@ export default startServerAndCreateNextHandler(apolloServer, {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function multiAuthMiddleware (request) {
|
||||
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
|
||||
|
||||
if (!request.cookies) {
|
||||
// required to properly access parsed cookies via request.cookies
|
||||
// and not unparsed via request.headers.cookie
|
||||
request = new NodeNextRequest(request)
|
||||
}
|
||||
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const hasCookiePointer = !!request.cookies[cookiePointerName]
|
||||
|
||||
const secure = process.env.NODE_ENV === 'production'
|
||||
|
||||
// is there a session?
|
||||
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
const hasSession = !!request.cookies[sessionCookieName]
|
||||
|
||||
if (!hasCookiePointer || !hasSession) {
|
||||
// no session or no cookie pointer. do nothing.
|
||||
return request
|
||||
}
|
||||
|
||||
const userId = request.cookies[cookiePointerName]
|
||||
if (userId === 'anonymous') {
|
||||
// user switched to anon. only delete session cookie.
|
||||
delete request.cookies[sessionCookieName]
|
||||
return request
|
||||
}
|
||||
|
||||
const userJWT = request.cookies[`multi_auth.${userId}`]
|
||||
if (!userJWT) {
|
||||
// no JWT for account switching found
|
||||
return request
|
||||
}
|
||||
|
||||
if (userJWT) {
|
||||
// use JWT found in cookie pointed to by cookie pointer
|
||||
request.cookies[sessionCookieName] = userJWT
|
||||
return request
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
@ -81,6 +81,7 @@ export default function LoginPage (props) {
|
||||
<Login
|
||||
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
|
||||
Header={() => <LoginHeader />}
|
||||
signin
|
||||
{...props}
|
||||
/>
|
||||
</StaticLayout>
|
||||
|
@ -131,8 +131,6 @@ function Enabled ({ setVaultKey, clearVault }) {
|
||||
placeholder=''
|
||||
required
|
||||
autoFocus
|
||||
as='textarea'
|
||||
rows={3}
|
||||
qr
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
|
109
prisma/migrations/20250308234113_user_sub_trust/migration.sql
Normal file
109
prisma/migrations/20250308234113_user_sub_trust/migration.sql
Normal file
@ -0,0 +1,109 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "subWeightedDownVotes" FLOAT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "subWeightedVotes" FLOAT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "Item.sumSubVotes_index" ON "Item"(("subWeightedVotes" - "subWeightedDownVotes"));
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserSubTrust" (
|
||||
"subName" CITEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"zapPostTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"subZapPostTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"zapCommentTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"subZapCommentTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserSubTrust_pkey" PRIMARY KEY ("userId","subName")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserSubTrust" ADD CONSTRAINT "UserSubTrust_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserSubTrust" ADD CONSTRAINT "UserSubTrust_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- UserSubTrust is NOT populated, so this is a no-op ... but useful having it written out for migrating manually on deployment
|
||||
UPDATE "Item"
|
||||
SET "subWeightedVotes" = subquery."subWeightedVotes",
|
||||
"subWeightedDownVotes" = subquery."subWeightedDownVotes",
|
||||
"weightedVotes" = subquery."weightedVotes",
|
||||
"weightedDownVotes" = subquery."weightedDownVotes"
|
||||
FROM (
|
||||
WITH sub_votes AS (
|
||||
SELECT "ItemAct"."itemId",
|
||||
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT > 0
|
||||
THEN COALESCE(
|
||||
LOG(
|
||||
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT / 1000
|
||||
) * CASE
|
||||
WHEN "Item"."parentId" IS NULL
|
||||
THEN "UserSubTrust"."subZapPostTrust"
|
||||
ELSE "UserSubTrust"."subZapCommentTrust"
|
||||
END, 0)
|
||||
ELSE 0
|
||||
END AS "subWeightedVotes",
|
||||
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT > 0
|
||||
THEN COALESCE(
|
||||
LOG(
|
||||
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT / 1000
|
||||
) * CASE
|
||||
WHEN "Item"."parentId" IS NULL
|
||||
THEN "UserSubTrust"."zapPostTrust"
|
||||
ELSE "UserSubTrust"."zapCommentTrust"
|
||||
END, 0)
|
||||
ELSE 0
|
||||
END AS "weightedVotes",
|
||||
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT > 0
|
||||
THEN COALESCE(
|
||||
LOG(
|
||||
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT / 1000
|
||||
) * CASE
|
||||
WHEN "Item"."parentId" IS NULL
|
||||
THEN "UserSubTrust"."subZapPostTrust"
|
||||
ELSE "UserSubTrust"."subZapCommentTrust"
|
||||
END, 0)
|
||||
ELSE 0
|
||||
END AS "subWeightedDownVotes",
|
||||
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT > 0
|
||||
THEN COALESCE(
|
||||
LOG(
|
||||
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT / 1000
|
||||
) * CASE
|
||||
WHEN "Item"."parentId" IS NULL
|
||||
THEN "UserSubTrust"."zapPostTrust"
|
||||
ELSE "UserSubTrust"."zapCommentTrust"
|
||||
END, 0)
|
||||
ELSE 0
|
||||
END AS "weightedDownVotes"
|
||||
FROM "ItemAct"
|
||||
JOIN "UserSubTrust" ON "ItemAct"."userId" = "UserSubTrust"."userId"
|
||||
JOIN "Item" ON "Item".id = "ItemAct"."itemId"
|
||||
AND "UserSubTrust"."subName" = "Item"."subName"
|
||||
AND "Item"."userId" <> "ItemAct"."userId"
|
||||
WHERE "ItemAct".act IN ('TIP', 'FEE', 'DONT_LIKE_THIS')
|
||||
GROUP BY "ItemAct"."itemId", "ItemAct"."userId", "UserSubTrust"."subZapPostTrust",
|
||||
"UserSubTrust"."subZapCommentTrust", "UserSubTrust"."zapPostTrust", "UserSubTrust"."zapCommentTrust",
|
||||
"Item"."parentId"
|
||||
)
|
||||
SELECT "itemId", SUM("subWeightedVotes") AS "subWeightedVotes", SUM("subWeightedDownVotes") AS "subWeightedDownVotes",
|
||||
SUM("weightedVotes") AS "weightedVotes", SUM("weightedDownVotes") AS "weightedDownVotes"
|
||||
FROM sub_votes
|
||||
GROUP BY "itemId"
|
||||
) AS subquery
|
||||
WHERE "Item".id = subquery."itemId";
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS hot_score_view AS
|
||||
SELECT id,
|
||||
("Item"."weightedVotes" - "Item"."weightedDownVotes" + ("Item"."weightedComments"*0.25) + ("Item".boost / 5000))
|
||||
/ POWER(GREATEST(3, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), 1.1) AS hot_score,
|
||||
("Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" + ("Item"."weightedComments"*0.25) + ("Item".boost / 5000))
|
||||
/ POWER(GREATEST(3, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), 1.1) AS sub_hot_score
|
||||
FROM "Item"
|
||||
WHERE "Item"."weightedVotes" > 0 OR "Item"."weightedDownVotes" > 0 OR "Item"."subWeightedVotes" > 0
|
||||
OR "Item"."subWeightedDownVotes" > 0 OR "Item"."weightedComments" > 0 OR "Item".boost > 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS hot_score_view_id_idx ON hot_score_view(id);
|
||||
CREATE INDEX IF NOT EXISTS hot_score_view_hot_score_idx ON hot_score_view(hot_score DESC NULLS LAST);
|
||||
CREATE INDEX IF NOT EXISTS hot_score_view_sub_hot_score_idx ON hot_score_view(sub_hot_score DESC NULLS LAST);
|
@ -0,0 +1,224 @@
|
||||
-- add limit and offset
|
||||
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
|
||||
_item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $4 '
|
||||
|| ' OFFSET $5) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, '
|
||||
|| ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
|
||||
|| ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|
||||
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
|
||||
|| ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
|
||||
|| ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|
||||
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
|
||||
|| ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|
||||
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
|
||||
|| ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|
||||
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| 'LEFT JOIN LATERAL ( '
|
||||
|| ' SELECT "itemId", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|
||||
|| ' FROM "ItemAct" '
|
||||
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|
||||
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|
||||
|| ' WHERE "ItemAct"."userId" = $3 '
|
||||
|| ' AND "ItemAct"."itemId" = "Item".id '
|
||||
|| ' GROUP BY "ItemAct"."itemId" '
|
||||
|| ') "ItemAct" ON true '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
|
||||
USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- add cowboy credits
|
||||
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id int, _level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS'
|
||||
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|
||||
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|
||||
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|
||||
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|
||||
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN users ON users.id = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"'
|
||||
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 AND "ThreadSubscription"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN LATERAL ( '
|
||||
|| ' SELECT "itemId", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|
||||
|| ' FROM "ItemAct" '
|
||||
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|
||||
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|
||||
|| ' WHERE "ItemAct"."userId" = $5 '
|
||||
|| ' AND "ItemAct"."itemId" = "Item".id '
|
||||
|| ' GROUP BY "ItemAct"."itemId" '
|
||||
|| ' ) "ItemAct" ON true '
|
||||
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' '
|
||||
USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
|
||||
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION item_comments(_item_id int, _level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS'
|
||||
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) as user, '
|
||||
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN users ON users.id = "Item"."userId" '
|
||||
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where
|
||||
USING _item_id, _level, _where, _order_by;
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments("Item".id, $2 - 1, $3, $4) AS comments '
|
||||
|| ' FROM t_item "Item"'
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _level, _where, _order_by;
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- add limit and offset
|
||||
CREATE OR REPLACE FUNCTION item_comments_limited(
|
||||
_item_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $2 '
|
||||
|| ' OFFSET $3) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) as user, '
|
||||
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| 'LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where
|
||||
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
||||
|
||||
|
@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS hot_score_view_hot_score_no_nulls_idx ON hot_score_view(hot_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS hot_score_view_sub_hot_score_no_nulls_idx ON hot_score_view(sub_hot_score DESC);
|
@ -0,0 +1,132 @@
|
||||
-- add limit and offset
|
||||
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
|
||||
_item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $4 '
|
||||
|| ' OFFSET $5) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|
||||
|| ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, '
|
||||
|| ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
|
||||
|| ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|
||||
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
|
||||
|| ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
|
||||
|| ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|
||||
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
|
||||
|| ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|
||||
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
|
||||
|| ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|
||||
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| 'LEFT JOIN LATERAL ( '
|
||||
|| ' SELECT "itemId", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|
||||
|| ' FROM "ItemAct" '
|
||||
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|
||||
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|
||||
|| ' WHERE "ItemAct"."userId" = $3 '
|
||||
|| ' AND "ItemAct"."itemId" = "Item".id '
|
||||
|| ' GROUP BY "ItemAct"."itemId" '
|
||||
|| ') "ItemAct" ON true '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
|
||||
USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION item_comments_limited(
|
||||
_item_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $2 '
|
||||
|| ' OFFSET $3) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|
||||
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) as user, '
|
||||
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| 'LEFT JOIN hot_score_view g ON g.id = "Item".id '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where
|
||||
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
@ -148,6 +148,7 @@ model User {
|
||||
directReceive Boolean @default(true)
|
||||
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
|
||||
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
|
||||
UserSubTrust UserSubTrust[]
|
||||
|
||||
@@index([photoId])
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
@ -184,6 +185,21 @@ model OneDayReferral {
|
||||
@@index([type, typeId])
|
||||
}
|
||||
|
||||
model UserSubTrust {
|
||||
subName String @db.Citext
|
||||
userId Int
|
||||
zapPostTrust Float @default(0)
|
||||
subZapPostTrust Float @default(0)
|
||||
zapCommentTrust Float @default(0)
|
||||
subZapCommentTrust Float @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
|
||||
|
||||
@@id([userId, subName])
|
||||
}
|
||||
|
||||
enum WalletType {
|
||||
LIGHTNING_ADDRESS
|
||||
LND
|
||||
@ -498,85 +514,87 @@ model Message {
|
||||
|
||||
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
|
||||
model Item {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
title String?
|
||||
text String?
|
||||
url String?
|
||||
userId Int
|
||||
parentId Int?
|
||||
path Unsupported("ltree")?
|
||||
pinId Int?
|
||||
latitude Float?
|
||||
location String?
|
||||
longitude Float?
|
||||
maxBid Int?
|
||||
maxSalary Int?
|
||||
minSalary Int?
|
||||
remote Boolean?
|
||||
subName String? @db.Citext
|
||||
statusUpdatedAt DateTime?
|
||||
status Status @default(ACTIVE)
|
||||
company String?
|
||||
weightedVotes Float @default(0)
|
||||
boost Int @default(0)
|
||||
oldBoost Int @default(0)
|
||||
pollCost Int?
|
||||
paidImgLink Boolean @default(false)
|
||||
commentMsats BigInt @default(0)
|
||||
commentMcredits BigInt @default(0)
|
||||
lastCommentAt DateTime?
|
||||
lastZapAt DateTime?
|
||||
ncomments Int @default(0)
|
||||
nDirectComments Int @default(0)
|
||||
msats BigInt @default(0)
|
||||
mcredits BigInt @default(0)
|
||||
cost Int @default(0)
|
||||
weightedDownVotes Float @default(0)
|
||||
bio Boolean @default(false)
|
||||
freebie Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
otsFile Bytes?
|
||||
otsHash String?
|
||||
imgproxyUrls Json?
|
||||
bounty Int?
|
||||
noteId String? @unique(map: "Item.noteId_unique")
|
||||
rootId Int?
|
||||
bountyPaidTo Int[]
|
||||
upvotes Int @default(0)
|
||||
weightedComments Float @default(0)
|
||||
Bookmark Bookmark[]
|
||||
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
|
||||
children Item[] @relation("ParentChildren")
|
||||
pin Pin? @relation(fields: [pinId], references: [id])
|
||||
root Item? @relation("RootDescendant", fields: [rootId], references: [id])
|
||||
descendants Item[] @relation("RootDescendant")
|
||||
sub Sub? @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
|
||||
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
|
||||
itemActs ItemAct[]
|
||||
mentions Mention[]
|
||||
itemReferrers ItemMention[] @relation("referrer")
|
||||
itemReferees ItemMention[] @relation("referee")
|
||||
pollOptions PollOption[]
|
||||
PollVote PollVote[]
|
||||
threadSubscriptions ThreadSubscription[]
|
||||
User User[]
|
||||
itemForwards ItemForward[]
|
||||
itemUploads ItemUpload[]
|
||||
uploadId Int?
|
||||
invoiceId Int?
|
||||
invoiceActionState InvoiceActionState?
|
||||
invoicePaidAt DateTime?
|
||||
outlawed Boolean @default(false)
|
||||
apiKey Boolean @default(false)
|
||||
pollExpiresAt DateTime?
|
||||
Ancestors Reply[] @relation("AncestorReplyItem")
|
||||
Replies Reply[]
|
||||
Reminder Reminder[]
|
||||
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
|
||||
PollBlindVote PollBlindVote[]
|
||||
ItemUserAgg ItemUserAgg[]
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
title String?
|
||||
text String?
|
||||
url String?
|
||||
userId Int
|
||||
parentId Int?
|
||||
path Unsupported("ltree")?
|
||||
pinId Int?
|
||||
latitude Float?
|
||||
location String?
|
||||
longitude Float?
|
||||
maxBid Int?
|
||||
maxSalary Int?
|
||||
minSalary Int?
|
||||
remote Boolean?
|
||||
subName String? @db.Citext
|
||||
statusUpdatedAt DateTime?
|
||||
status Status @default(ACTIVE)
|
||||
company String?
|
||||
weightedVotes Float @default(0)
|
||||
subWeightedVotes Float @default(0)
|
||||
boost Int @default(0)
|
||||
oldBoost Int @default(0)
|
||||
pollCost Int?
|
||||
paidImgLink Boolean @default(false)
|
||||
commentMsats BigInt @default(0)
|
||||
commentMcredits BigInt @default(0)
|
||||
lastCommentAt DateTime?
|
||||
lastZapAt DateTime?
|
||||
ncomments Int @default(0)
|
||||
nDirectComments Int @default(0)
|
||||
msats BigInt @default(0)
|
||||
mcredits BigInt @default(0)
|
||||
cost Int @default(0)
|
||||
weightedDownVotes Float @default(0)
|
||||
subWeightedDownVotes Float @default(0)
|
||||
bio Boolean @default(false)
|
||||
freebie Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
otsFile Bytes?
|
||||
otsHash String?
|
||||
imgproxyUrls Json?
|
||||
bounty Int?
|
||||
noteId String? @unique(map: "Item.noteId_unique")
|
||||
rootId Int?
|
||||
bountyPaidTo Int[]
|
||||
upvotes Int @default(0)
|
||||
weightedComments Float @default(0)
|
||||
Bookmark Bookmark[]
|
||||
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
|
||||
children Item[] @relation("ParentChildren")
|
||||
pin Pin? @relation(fields: [pinId], references: [id])
|
||||
root Item? @relation("RootDescendant", fields: [rootId], references: [id])
|
||||
descendants Item[] @relation("RootDescendant")
|
||||
sub Sub? @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
|
||||
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
|
||||
itemActs ItemAct[]
|
||||
mentions Mention[]
|
||||
itemReferrers ItemMention[] @relation("referrer")
|
||||
itemReferees ItemMention[] @relation("referee")
|
||||
pollOptions PollOption[]
|
||||
PollVote PollVote[]
|
||||
threadSubscriptions ThreadSubscription[]
|
||||
User User[]
|
||||
itemForwards ItemForward[]
|
||||
itemUploads ItemUpload[]
|
||||
uploadId Int?
|
||||
invoiceId Int?
|
||||
invoiceActionState InvoiceActionState?
|
||||
invoicePaidAt DateTime?
|
||||
outlawed Boolean @default(false)
|
||||
apiKey Boolean @default(false)
|
||||
pollExpiresAt DateTime?
|
||||
Ancestors Reply[] @relation("AncestorReplyItem")
|
||||
Replies Reply[]
|
||||
Reminder Reminder[]
|
||||
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
|
||||
PollBlindVote PollBlindVote[]
|
||||
ItemUserAgg ItemUserAgg[]
|
||||
|
||||
@@index([uploadId])
|
||||
@@index([lastZapAt])
|
||||
@ -760,6 +778,7 @@ model Sub {
|
||||
MuteSub MuteSub[]
|
||||
SubSubscription SubSubscription[]
|
||||
TerritoryTransfer TerritoryTransfer[]
|
||||
UserSubTrust UserSubTrust[]
|
||||
|
||||
@@index([parentName])
|
||||
@@index([createdAt])
|
||||
|
@ -33,7 +33,7 @@ export async function createInvoice (
|
||||
out: false
|
||||
})
|
||||
|
||||
let hostname = url.replace(/^https?:\/\//, '')
|
||||
let hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
const agent = getAgent({ hostname })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production' && hostname.startsWith('localhost:')) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { fetchWithTimeout } from '@/lib/fetch'
|
||||
import { msatsToSats } from '@/lib/format'
|
||||
import { getAgent } from '@/lib/proxy'
|
||||
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
|
||||
|
||||
export * from '@/wallets/phoenixd'
|
||||
@ -27,9 +28,13 @@ export async function createInvoice (
|
||||
body.append('description', description)
|
||||
body.append('amountSat', msatsToSats(msats))
|
||||
|
||||
const res = await fetchWithTimeout(url + path, {
|
||||
const hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
const agent = getAgent({ hostname })
|
||||
|
||||
const res = await fetchWithTimeout(`${agent.protocol}//${hostname}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent,
|
||||
body,
|
||||
signal
|
||||
})
|
||||
|
@ -14,6 +14,7 @@ export async function expireBoost ({ data: { id }, models }) {
|
||||
FROM "ItemAct"
|
||||
WHERE act = 'BOOST'
|
||||
AND "itemId" = ${Number(id)}::INTEGER
|
||||
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
|
||||
)
|
||||
UPDATE "Item"
|
||||
SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000
|
||||
|
274
worker/trust.js
274
worker/trust.js
@ -1,38 +1,89 @@
|
||||
import * as math from 'mathjs'
|
||||
import { USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
|
||||
import { USER_ID } from '@/lib/constants'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { initialTrust, GLOBAL_SEEDS } from '@/api/paidAction/lib/territory'
|
||||
|
||||
export async function trust ({ boss, models }) {
|
||||
try {
|
||||
console.time('trust')
|
||||
console.timeLog('trust', 'getting graph')
|
||||
const graph = await getGraph(models)
|
||||
console.timeLog('trust', 'computing trust')
|
||||
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
|
||||
console.timeLog('trust', 'storing trust')
|
||||
await storeTrust(models, graph, vGlobal, mPersonal)
|
||||
} finally {
|
||||
console.timeEnd('trust')
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 10
|
||||
const MAX_DEPTH = 40
|
||||
const MAX_TRUST = 1
|
||||
const MIN_SUCCESS = 1
|
||||
const MIN_SUCCESS = 0
|
||||
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
|
||||
const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
|
||||
const GLOBAL_ROOT = 616
|
||||
const SEED_WEIGHT = 1.0
|
||||
const SEED_WEIGHT = 0.83
|
||||
const AGAINST_MSAT_MIN = 1000
|
||||
const MSAT_MIN = 20001 // 20001 is the minimum for a tip to be counted in trust
|
||||
const SIG_DIFF = 0.1 // need to differ by at least 10 percent
|
||||
const MSAT_MIN = 1001 // 20001 is the minimum for a tip to be counted in trust
|
||||
const INDEPENDENCE_THRESHOLD = 50 // how many zappers are needed to consider a sub independent
|
||||
const IRRELEVANT_CUMULATIVE_TRUST = 0.001 // if a user has less than this amount of cumulative trust, they are irrelevant
|
||||
|
||||
// for each subName, we'll need to get two graphs
|
||||
// one for comments and one for posts
|
||||
// then we'll need to do two trust calculations on each graph
|
||||
// one with global seeds and one with subName seeds
|
||||
export async function trust ({ boss, models }) {
|
||||
console.time('trust')
|
||||
const territories = await models.sub.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
})
|
||||
for (const territory of territories) {
|
||||
const seeds = GLOBAL_SEEDS.includes(territory.userId) ? GLOBAL_SEEDS : GLOBAL_SEEDS.concat(territory.userId)
|
||||
try {
|
||||
console.timeLog('trust', `getting post graph for ${territory.name}`)
|
||||
const postGraph = await getGraph(models, territory.name, true, seeds)
|
||||
console.timeLog('trust', `getting comment graph for ${territory.name}`)
|
||||
const commentGraph = await getGraph(models, territory.name, false, seeds)
|
||||
console.timeLog('trust', `computing global post trust for ${territory.name}`)
|
||||
const vGlobalPost = await trustGivenGraph(postGraph)
|
||||
console.timeLog('trust', `computing global comment trust for ${territory.name}`)
|
||||
const vGlobalComment = await trustGivenGraph(commentGraph)
|
||||
console.timeLog('trust', `computing sub post trust for ${territory.name}`)
|
||||
const vSubPost = await trustGivenGraph(postGraph, postGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds)
|
||||
console.timeLog('trust', `computing sub comment trust for ${territory.name}`)
|
||||
const vSubComment = await trustGivenGraph(commentGraph, commentGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds)
|
||||
console.timeLog('trust', `storing trust for ${territory.name}`)
|
||||
let results = reduceVectors(territory.name, {
|
||||
zapPostTrust: {
|
||||
graph: postGraph,
|
||||
vector: vGlobalPost
|
||||
},
|
||||
subZapPostTrust: {
|
||||
graph: postGraph,
|
||||
vector: vSubPost
|
||||
},
|
||||
zapCommentTrust: {
|
||||
graph: commentGraph,
|
||||
vector: vGlobalComment
|
||||
},
|
||||
subZapCommentTrust: {
|
||||
graph: commentGraph,
|
||||
vector: vSubComment
|
||||
}
|
||||
})
|
||||
|
||||
if (results.length === 0) {
|
||||
console.timeLog('trust', `no results for ${territory.name} - adding seeds`)
|
||||
results = initialTrust({ name: territory.name, userId: territory.userId })
|
||||
}
|
||||
|
||||
await storeTrust(models, territory.name, results)
|
||||
} catch (e) {
|
||||
console.error(`error computing trust for ${territory.name}:`, e)
|
||||
} finally {
|
||||
console.timeLog('trust', `finished computing trust for ${territory.name}`)
|
||||
}
|
||||
}
|
||||
console.timeEnd('trust')
|
||||
}
|
||||
|
||||
/*
|
||||
Given a graph and start this function returns an object where
|
||||
the keys are the node id and their value is the trust of that node
|
||||
*/
|
||||
function trustGivenGraph (graph) {
|
||||
// I'm going to need to send subName, and multiply by a vector instead of a matrix
|
||||
function trustGivenGraph (graph, seeds = GLOBAL_SEEDS) {
|
||||
console.timeLog('trust', `creating matrix of size ${graph.length} x ${graph.length}`)
|
||||
// empty matrix of proper size nstackers x nstackers
|
||||
let mat = math.zeros(graph.length, graph.length, 'sparse')
|
||||
const mat = math.zeros(graph.length, graph.length, 'sparse')
|
||||
|
||||
// create a map of user id to position in matrix
|
||||
const posByUserId = {}
|
||||
@ -54,54 +105,57 @@ function trustGivenGraph (graph) {
|
||||
|
||||
// perform random walk over trust matrix
|
||||
// the resulting matrix columns represent the trust a user (col) has for each other user (rows)
|
||||
// XXX this scales N^3 and mathjs is slow
|
||||
let matT = math.transpose(mat)
|
||||
const original = matT.clone()
|
||||
for (let i = 0; i < MAX_DEPTH; i++) {
|
||||
console.timeLog('trust', `matrix multiply ${i}`)
|
||||
matT = math.multiply(original, matT)
|
||||
matT = math.add(math.multiply(1 - SEED_WEIGHT, matT), math.multiply(SEED_WEIGHT, original))
|
||||
const matT = math.transpose(mat)
|
||||
const vTrust = math.zeros(graph.length)
|
||||
for (const seed of seeds) {
|
||||
vTrust.set([posByUserId[seed], 0], 1.0 / seeds.length)
|
||||
}
|
||||
let result = vTrust.clone()
|
||||
console.timeLog('trust', 'matrix multiply')
|
||||
for (let i = 0; i < MAX_DEPTH; i++) {
|
||||
result = math.multiply(matT, result)
|
||||
result = math.add(math.multiply(1 - SEED_WEIGHT, result), math.multiply(SEED_WEIGHT, vTrust))
|
||||
}
|
||||
result = math.squeeze(result)
|
||||
|
||||
console.timeLog('trust', 'transforming result')
|
||||
|
||||
const seedIdxs = SN_ADMIN_IDS.map(id => posByUserId[id])
|
||||
const isOutlier = (fromIdx, idx) => [...seedIdxs, fromIdx].includes(idx)
|
||||
const sqapply = (mat, fn) => {
|
||||
let idx = 0
|
||||
return math.squeeze(math.apply(mat, 1, d => {
|
||||
const filtered = math.filter(d, (val, fidx) => {
|
||||
return val !== 0 && !isOutlier(idx, fidx[0])
|
||||
})
|
||||
idx++
|
||||
if (filtered.length === 0) return 0
|
||||
return fn(filtered)
|
||||
}))
|
||||
const seedIdxs = seeds.map(id => posByUserId[id])
|
||||
const filterZeroAndSeed = (val, idx) => {
|
||||
return val !== 0 && !seedIdxs.includes(idx[0])
|
||||
}
|
||||
const filterSeed = (val, idx) => {
|
||||
return !seedIdxs.includes(idx[0])
|
||||
}
|
||||
const sqapply = (vec, filterFn, fn) => {
|
||||
// if the vector is smaller than the seeds, don't filter
|
||||
const filtered = vec.size()[0] > seeds.length ? math.filter(vec, filterFn) : vec
|
||||
if (filtered.size()[0] === 0) return 0
|
||||
return fn(filtered)
|
||||
}
|
||||
|
||||
console.timeLog('trust', 'normalizing')
|
||||
console.timeLog('trust', 'stats')
|
||||
mat = math.transpose(matT)
|
||||
const std = sqapply(mat, math.std) // math.squeeze(math.std(mat, 1))
|
||||
const mean = sqapply(mat, math.mean) // math.squeeze(math.mean(mat, 1))
|
||||
const zscore = math.map(mat, (val, idx) => {
|
||||
const zstd = math.subset(std, math.index(idx[0], 0))
|
||||
const zmean = math.subset(mean, math.index(idx[0], 0))
|
||||
return zstd ? (val - zmean) / zstd : 0
|
||||
const std = sqapply(result, filterZeroAndSeed, math.std) // math.squeeze(math.std(mat, 1))
|
||||
const mean = sqapply(result, filterZeroAndSeed, math.mean) // math.squeeze(math.mean(mat, 1))
|
||||
console.timeLog('trust', 'std', std)
|
||||
console.timeLog('trust', 'mean', mean)
|
||||
const zscore = math.map(result, (val) => {
|
||||
if (std === 0) return 0
|
||||
return (val - mean) / std
|
||||
})
|
||||
console.timeLog('trust', 'minmax')
|
||||
const min = sqapply(zscore, math.min) // math.squeeze(math.min(zscore, 1))
|
||||
const max = sqapply(zscore, math.max) // math.squeeze(math.max(zscore, 1))
|
||||
const mPersonal = math.map(zscore, (val, idx) => {
|
||||
const zmin = math.subset(min, math.index(idx[0], 0))
|
||||
const zmax = math.subset(max, math.index(idx[0], 0))
|
||||
const zrange = zmax - zmin
|
||||
if (val > zmax) return MAX_TRUST
|
||||
return zrange ? (val - zmin) / zrange : 0
|
||||
const min = sqapply(zscore, filterSeed, math.min) // math.squeeze(math.min(zscore, 1))
|
||||
const max = sqapply(zscore, filterSeed, math.max) // math.squeeze(math.max(zscore, 1))
|
||||
console.timeLog('trust', 'min', min)
|
||||
console.timeLog('trust', 'max', max)
|
||||
const normalized = math.map(zscore, (val) => {
|
||||
const zrange = max - min
|
||||
if (val > max) return MAX_TRUST
|
||||
return zrange ? (val - min) / zrange : 0
|
||||
})
|
||||
const vGlobal = math.squeeze(math.row(mPersonal, posByUserId[GLOBAL_ROOT]))
|
||||
|
||||
return [vGlobal, mPersonal]
|
||||
return normalized
|
||||
}
|
||||
|
||||
/*
|
||||
@ -111,23 +165,31 @@ function trustGivenGraph (graph) {
|
||||
...
|
||||
]
|
||||
*/
|
||||
async function getGraph (models) {
|
||||
// I'm going to want to send subName to this function
|
||||
// and whether it's for comments or posts
|
||||
async function getGraph (models, subName, postTrust = true, seeds = GLOBAL_SEEDS) {
|
||||
return await models.$queryRaw`
|
||||
SELECT id, json_agg(json_build_object(
|
||||
'node', oid,
|
||||
'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops
|
||||
FROM (
|
||||
WITH user_votes AS (
|
||||
SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, min("ItemAct".created_at) AS act_at,
|
||||
SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, max("ItemAct".created_at) AS act_at,
|
||||
users.created_at AS user_at, "ItemAct".act = 'DONT_LIKE_THIS' AS against,
|
||||
count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count,
|
||||
sum("ItemAct".msats) as user_msats
|
||||
FROM "ItemAct"
|
||||
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
|
||||
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
|
||||
AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
|
||||
AND ${postTrust
|
||||
? Prisma.sql`"Item"."parentId" IS NULL AND "Item"."subName" = ${subName}::TEXT`
|
||||
: Prisma.sql`
|
||||
"Item"."parentId" IS NOT NULL
|
||||
JOIN "Item" root ON "Item"."rootId" = root.id AND root."subName" = ${subName}::TEXT`
|
||||
}
|
||||
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
|
||||
WHERE "ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'
|
||||
GROUP BY user_id, name, item_id, user_at, against
|
||||
WHERE ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
|
||||
GROUP BY user_id, users.name, item_id, user_at, against
|
||||
HAVING CASE WHEN
|
||||
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
|
||||
ELSE sum("ItemAct".msats) > ${MSAT_MIN} END
|
||||
@ -136,7 +198,7 @@ async function getGraph (models) {
|
||||
SELECT a.user_id AS a_id, b.user_id AS b_id,
|
||||
sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE a.act_at > b.act_at AND a.against = b.against) AS before,
|
||||
sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE b.act_at > a.act_at AND a.against = b.against) AS after,
|
||||
sum(log(1 + a.user_msats / 10000::float) + log(1 + b.user_msats / 10000::float)) FILTER(WHERE a.against <> b.against) AS disagree,
|
||||
count(*) FILTER(WHERE a.against <> b.against) AS disagree,
|
||||
b.user_vote_count AS b_total, a.user_vote_count AS a_total
|
||||
FROM user_votes a
|
||||
JOIN user_votes b ON a.item_id = b.item_id
|
||||
@ -149,14 +211,9 @@ async function getGraph (models) {
|
||||
confidence(before - disagree, b_total - after, ${Z_CONFIDENCE})
|
||||
ELSE 0 END AS trust
|
||||
FROM user_pair
|
||||
WHERE NOT (b_id = ANY (${SN_ADMIN_IDS}))
|
||||
UNION ALL
|
||||
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust
|
||||
FROM user_pair, unnest(${SN_ADMIN_IDS}::int[]) seed_id
|
||||
GROUP BY a_id, a_total, seed_id
|
||||
UNION ALL
|
||||
SELECT a_id AS id, a_id AS oid, ${MAX_TRUST}::float as trust
|
||||
FROM user_pair
|
||||
SELECT seed_id AS id, seed_id AS oid, 0 AS trust
|
||||
FROM unnest(${seeds}::int[]) seed_id
|
||||
)
|
||||
SELECT id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust
|
||||
FROM trust_pairs
|
||||
@ -165,46 +222,45 @@ async function getGraph (models) {
|
||||
ORDER BY id ASC`
|
||||
}
|
||||
|
||||
async function storeTrust (models, graph, vGlobal, mPersonal) {
|
||||
// convert nodeTrust into table literal string
|
||||
let globalValues = ''
|
||||
let personalValues = ''
|
||||
vGlobal.forEach((val, [idx]) => {
|
||||
if (isNaN(val)) return
|
||||
if (globalValues) globalValues += ','
|
||||
globalValues += `(${graph[idx].id}, ${val}::FLOAT)`
|
||||
if (personalValues) personalValues += ','
|
||||
personalValues += `(${GLOBAL_ROOT}, ${graph[idx].id}, ${val}::FLOAT)`
|
||||
})
|
||||
function reduceVectors (subName, fieldGraphVectors) {
|
||||
function reduceVector (field, graph, vector, result = {}) {
|
||||
vector.forEach((val, [idx]) => {
|
||||
if (isNaN(val) || val <= 0) return
|
||||
result[graph[idx].id] = {
|
||||
...result[graph[idx].id],
|
||||
subName,
|
||||
userId: graph[idx].id,
|
||||
[field]: val
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
math.forEach(mPersonal, (val, [fromIdx, toIdx]) => {
|
||||
const globalVal = vGlobal.get([toIdx, 0])
|
||||
if (isNaN(val) || val - globalVal <= SIG_DIFF) return
|
||||
if (personalValues) personalValues += ','
|
||||
personalValues += `(${graph[fromIdx].id}, ${graph[toIdx].id}, ${val}::FLOAT)`
|
||||
})
|
||||
let result = {}
|
||||
for (const field in fieldGraphVectors) {
|
||||
result = reduceVector(field, fieldGraphVectors[field].graph, fieldGraphVectors[field].vector, result)
|
||||
}
|
||||
|
||||
// return only the users with trust > 0
|
||||
return Object.values(result).filter(s =>
|
||||
Object.keys(fieldGraphVectors).reduce(
|
||||
(acc, key) => acc + (s[key] ?? 0),
|
||||
0
|
||||
) > IRRELEVANT_CUMULATIVE_TRUST
|
||||
)
|
||||
}
|
||||
|
||||
async function storeTrust (models, subName, results) {
|
||||
console.timeLog('trust', `storing trust for ${subName} with ${results.length} users`)
|
||||
// update the trust of each user in graph
|
||||
await models.$transaction([
|
||||
models.$executeRaw`UPDATE users SET trust = 0`,
|
||||
models.$executeRawUnsafe(
|
||||
`UPDATE users
|
||||
SET trust = g.trust
|
||||
FROM (values ${globalValues}) g(id, trust)
|
||||
WHERE users.id = g.id`),
|
||||
models.$executeRawUnsafe(
|
||||
`INSERT INTO "Arc" ("fromId", "toId", "zapTrust")
|
||||
SELECT id, oid, trust
|
||||
FROM (values ${personalValues}) g(id, oid, trust)
|
||||
ON CONFLICT ("fromId", "toId") DO UPDATE SET "zapTrust" = EXCLUDED."zapTrust"`
|
||||
),
|
||||
// select all arcs that don't exist in personalValues and delete them
|
||||
models.$executeRawUnsafe(
|
||||
`DELETE FROM "Arc"
|
||||
WHERE ("fromId", "toId") NOT IN (
|
||||
SELECT id, oid
|
||||
FROM (values ${personalValues}) g(id, oid, trust)
|
||||
)`
|
||||
)
|
||||
models.userSubTrust.deleteMany({
|
||||
where: {
|
||||
subName
|
||||
}
|
||||
}),
|
||||
models.userSubTrust.createMany({
|
||||
data: results
|
||||
})
|
||||
])
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ export async function rankViews () {
|
||||
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
|
||||
|
||||
try {
|
||||
for (const view of ['zap_rank_personal_view']) {
|
||||
for (const view of ['hot_score_view']) {
|
||||
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
||||
}
|
||||
} finally {
|
||||
|
Loading…
x
Reference in New Issue
Block a user