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>
|
</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)
|
- What You See is What We Ship (look ma, I invented an initialism)
|
||||||
- 100% FOSS
|
- 100% FOSS
|
||||||
- We pay bitcoin for PRs, issues, documentation, code reviews and more
|
- We pay bitcoin for PRs, issues, documentation, code reviews and more
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
|
|
||||||
@ -48,9 +49,9 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
|||||||
let itemAct
|
let itemAct
|
||||||
if (invoice) {
|
if (invoice) {
|
||||||
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
|
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) {
|
} else if (actId) {
|
||||||
itemAct = await tx.itemAct.findUnique({ where: { id: actId } })
|
itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } })
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No invoice or actId')
|
throw new Error('No invoice or actId')
|
||||||
}
|
}
|
||||||
@ -60,8 +61,22 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
|||||||
|
|
||||||
// denormalize downzaps
|
// denormalize downzaps
|
||||||
await tx.$executeRaw`
|
await tx.$executeRaw`
|
||||||
WITH zapper AS (
|
WITH territory AS (
|
||||||
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
|
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 (
|
), zap AS (
|
||||||
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
|
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
|
||||||
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
||||||
@ -70,7 +85,8 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
|||||||
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
||||||
)
|
)
|
||||||
UPDATE "Item"
|
UPDATE "Item"
|
||||||
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats)
|
SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats,
|
||||||
|
"subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats
|
||||||
FROM zap, zapper
|
FROM zap, zapper
|
||||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
|
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
|
||||||
}
|
}
|
||||||
|
@ -252,15 +252,18 @@ export async function onPaid ({ invoice, id }, context) {
|
|||||||
JOIN users ON "Item"."userId" = users.id
|
JOIN users ON "Item"."userId" = users.id
|
||||||
WHERE "Item".id = ${item.id}::INTEGER
|
WHERE "Item".id = ${item.id}::INTEGER
|
||||||
), ancestors AS (
|
), 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"
|
UPDATE "Item"
|
||||||
SET ncomments = "Item".ncomments + 1,
|
SET ncomments = "Item".ncomments + 1,
|
||||||
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
|
"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" +
|
"nDirectComments" = "Item"."nDirectComments" +
|
||||||
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
|
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
|
||||||
FROM comment
|
FROM comment, ancestors
|
||||||
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
|
WHERE "Item".id = ancestors.id
|
||||||
RETURNING "Item".*
|
RETURNING "Item".*
|
||||||
)
|
)
|
||||||
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
|
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 { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { nextBilling } from '@/lib/territory'
|
import { nextBilling } from '@/lib/territory'
|
||||||
|
import { initialTrust } from './lib/territory'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
|
|
||||||
@ -20,7 +21,7 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
|||||||
const billedLastAt = new Date()
|
const billedLastAt = new Date()
|
||||||
const billPaidUntil = nextBilling(billedLastAt, billingType)
|
const billPaidUntil = nextBilling(billedLastAt, billingType)
|
||||||
|
|
||||||
return await tx.sub.create({
|
const sub = await tx.sub.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
billedLastAt,
|
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 }) {
|
export async function describe ({ name }) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { nextBilling } from '@/lib/territory'
|
import { nextBilling } from '@/lib/territory'
|
||||||
|
import { initialTrust } from './lib/territory'
|
||||||
|
|
||||||
export const anonable = false
|
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,
|
data,
|
||||||
// optimistic concurrency control
|
// optimistic concurrency control
|
||||||
// make sure none of the relevant fields have changed since we fetched the sub
|
// 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) {
|
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 { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { notifyZapped } from '@/lib/webPush'
|
import { notifyZapped } from '@/lib/webPush'
|
||||||
import { getInvoiceableWallets } from '@/wallets/server'
|
import { getInvoiceableWallets } from '@/wallets/server'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export const anonable = true
|
export const anonable = true
|
||||||
|
|
||||||
@ -149,8 +150,22 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
|||||||
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
|
// 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
|
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
||||||
await tx.$queryRaw`
|
await tx.$queryRaw`
|
||||||
WITH zapper AS (
|
WITH territory AS (
|
||||||
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
|
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 (
|
), zap AS (
|
||||||
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
|
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
|
||||||
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
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()
|
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
|
||||||
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
|
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
|
||||||
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
||||||
)
|
), item_zapped AS (
|
||||||
UPDATE "Item"
|
UPDATE "Item"
|
||||||
SET
|
SET
|
||||||
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
|
"weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats,
|
||||||
|
"subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats,
|
||||||
upvotes = upvotes + zap.first_vote,
|
upvotes = upvotes + zap.first_vote,
|
||||||
msats = "Item".msats + ${msats}::BIGINT,
|
msats = "Item".msats + ${msats}::BIGINT,
|
||||||
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
|
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
|
||||||
"lastZapAt" = now()
|
"lastZapAt" = now()
|
||||||
FROM zap, zapper
|
FROM zap, zapper
|
||||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER
|
WHERE "Item".id = ${itemAct.itemId}::INTEGER
|
||||||
RETURNING "Item".*`
|
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 "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
|
// record potential bounty payment
|
||||||
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
|
// 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)
|
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
|
||||||
FROM bounty
|
FROM bounty
|
||||||
WHERE "Item".id = bounty.id AND bounty.paid`
|
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 }) {
|
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`
|
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (me && sort === 'hot') {
|
if (sort === 'hot') {
|
||||||
return `ORDER BY ${sharedSorts},
|
return `ORDER BY ${sharedSorts},
|
||||||
"personal_hot_score" DESC NULLS LAST,
|
"hotScore" DESC NULLS LAST,
|
||||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
"Item".msats DESC, "Item".id DESC`
|
||||||
} else {
|
} else {
|
||||||
if (sort === 'top') {
|
return `ORDER BY ${sharedSorts}, "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
|
||||||
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`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,14 +134,14 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
|
|||||||
}, ...subArr))?.[0] || null
|
}, ...subArr))?.[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderByClause = (by, me, models, type) => {
|
const orderByClause = (by, me, models, type, sub) => {
|
||||||
switch (by) {
|
switch (by) {
|
||||||
case 'comments':
|
case 'comments':
|
||||||
return 'ORDER BY "Item".ncomments DESC'
|
return 'ORDER BY "Item".ncomments DESC'
|
||||||
case 'sats':
|
case 'sats':
|
||||||
return 'ORDER BY "Item".msats DESC'
|
return 'ORDER BY "Item".msats DESC'
|
||||||
case 'zaprank':
|
case 'zaprank':
|
||||||
return topOrderByWeightedSats(me, models)
|
return topOrderByWeightedSats(me, models, sub)
|
||||||
case 'boost':
|
case 'boost':
|
||||||
return 'ORDER BY "Item".boost DESC'
|
return 'ORDER BY "Item".boost DESC'
|
||||||
case 'random':
|
case 'random':
|
||||||
@ -155,22 +151,8 @@ const orderByClause = (by, me, models, type) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) {
|
export function joinHotScoreView (me, models) {
|
||||||
return `((CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
|
return ' JOIN hot_score_view g ON g.id = "Item".id '
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// this grabs all the stuff we need to display the item list and only
|
// 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),
|
await filterClause(me, models, type),
|
||||||
by === 'boost' && '"Item".boost > 0',
|
by === 'boost' && '"Item".boost > 0',
|
||||||
muteClause(me))}
|
muteClause(me))}
|
||||||
${orderByClause(by || 'zaprank', me, models, type)}
|
${orderByClause(by || 'zaprank', me, models, type, sub)}
|
||||||
OFFSET $3
|
OFFSET $3
|
||||||
LIMIT $4`,
|
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)
|
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
|
||||||
break
|
break
|
||||||
case 'random':
|
case 'random':
|
||||||
@ -571,10 +553,10 @@ export default {
|
|||||||
me,
|
me,
|
||||||
models,
|
models,
|
||||||
query: `
|
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"
|
FROM "Item"
|
||||||
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
||||||
${joinZapRankPersonalView(me, models)}
|
${joinHotScoreView(me, models)}
|
||||||
${whereClause(
|
${whereClause(
|
||||||
// in home (sub undefined), filter out global pinned items since we inject them later
|
// 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)',
|
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),
|
await filterClause(me, models, type),
|
||||||
subClause(sub, 3, 'Item', me, showNsfw),
|
subClause(sub, 3, 'Item', me, showNsfw),
|
||||||
muteClause(me))}
|
muteClause(me))}
|
||||||
ORDER BY rank DESC
|
ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC
|
||||||
OFFSET $1
|
OFFSET $1
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
orderBy: 'ORDER BY rank DESC'
|
orderBy: `ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC`
|
||||||
}, decodedCursor.offset, limit, ...subArr)
|
}, 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
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@ -1574,6 +1527,9 @@ export const SELECT =
|
|||||||
`SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
|
`SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
|
||||||
ltree2text("Item"."path") AS "path"`
|
ltree2text("Item"."path") AS "path"`
|
||||||
|
|
||||||
function topOrderByWeightedSats (me, models) {
|
function topOrderByWeightedSats (me, models, sub) {
|
||||||
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
|
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 }) => {
|
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
let sitems = null
|
let sitems = null
|
||||||
let termQueries = []
|
|
||||||
|
|
||||||
// short circuit: return empty result if either:
|
// short circuit: return empty result if either:
|
||||||
// 1. no query provided, or
|
// 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) {
|
switch (what) {
|
||||||
case 'posts':
|
case 'posts': // posts only
|
||||||
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
|
filters.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
|
||||||
break
|
break
|
||||||
case 'comments':
|
case 'comments': // comments only
|
||||||
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
|
filters.push({ bool: { must: { exists: { field: 'parentId' } } } })
|
||||||
break
|
break
|
||||||
case 'bookmarks':
|
case 'bookmarks':
|
||||||
if (me?.id) {
|
if (me?.id) {
|
||||||
whatArr.push({ match: { bookmarkedBy: me?.id } })
|
filters.push({ match: { bookmarkedBy: me?.id } })
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
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)
|
const { query: _query, quotes, nym, url, territory } = queryParts(q)
|
||||||
let query = _query
|
const query = _query
|
||||||
|
|
||||||
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
|
|
||||||
|
|
||||||
|
// if search contains a url term, modify the query text
|
||||||
if (url) {
|
if (url) {
|
||||||
const isFQDN = url.startsWith('url:www.')
|
const uri = url.slice(4)
|
||||||
const domain = isFQDN ? url.slice(8) : url.slice(4)
|
let uriObj
|
||||||
const fqdn = `www.${domain}`
|
try {
|
||||||
query = (isUrlSearch) ? `${domain} ${fqdn}` : `${query.trim()} ${domain}`
|
uriObj = new URL(uri)
|
||||||
}
|
} catch {
|
||||||
|
try {
|
||||||
if (nym) {
|
uriObj = new URL(`https://${uri}`)
|
||||||
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
|
} catch {}
|
||||||
}
|
|
||||||
|
|
||||||
if (territory) {
|
|
||||||
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
termQueries.push({
|
termQueries.push({
|
||||||
// all terms are matched in fields
|
|
||||||
multi_match: {
|
multi_match: {
|
||||||
query,
|
query: quote,
|
||||||
type: 'best_fields',
|
type: 'phrase',
|
||||||
fields: ['title^100', 'text'],
|
fields: ['title', 'text']
|
||||||
minimum_should_match: (isUrlSearch) ? 1 : '100%',
|
|
||||||
boost: 1000
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const quote of quotes) {
|
// force the search to include the quoted phrase
|
||||||
whatArr.push({
|
filters.push({
|
||||||
multi_match: {
|
multi_match: {
|
||||||
query: quote,
|
query: quote,
|
||||||
type: 'phrase',
|
type: 'phrase',
|
||||||
@ -244,84 +303,104 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we search for an exact string only, everything must match
|
// functions for boosting search rank by recency or popularity
|
||||||
// so score purely on sort field
|
|
||||||
let boostMode = query ? 'multiply' : 'replace'
|
|
||||||
let sortField
|
|
||||||
let sortMod = 'log1p'
|
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'comments':
|
case 'comments':
|
||||||
sortField = 'ncomments'
|
functions.push({
|
||||||
sortMod = 'square'
|
field_value_factor: {
|
||||||
|
field: 'ncomments',
|
||||||
|
modifier: 'log1p'
|
||||||
|
}
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 'sats':
|
case 'sats':
|
||||||
sortField = 'sats'
|
functions.push({
|
||||||
|
field_value_factor: {
|
||||||
|
field: 'sats',
|
||||||
|
modifier: 'log1p'
|
||||||
|
}
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 'recent':
|
case 'recent':
|
||||||
sortField = 'createdAt'
|
functions.push({
|
||||||
sortMod = 'square'
|
gauss: {
|
||||||
boostMode = 'replace'
|
createdAt: {
|
||||||
|
origin: 'now',
|
||||||
|
scale: '7d',
|
||||||
|
decay: 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'zaprank':
|
||||||
|
functions.push({
|
||||||
|
field_value_factor: {
|
||||||
|
field: 'wvotes',
|
||||||
|
modifier: 'log1p'
|
||||||
|
}
|
||||||
|
})
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
sortField = 'wvotes'
|
|
||||||
sortMod = 'none'
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const functions = [
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// query for search terms
|
||||||
|
if (query.length) {
|
||||||
|
// keyword based subquery, to be used on its own or in conjunction with a neural
|
||||||
|
// search
|
||||||
|
const subquery = [
|
||||||
{
|
{
|
||||||
field_value_factor: {
|
multi_match: {
|
||||||
field: sortField,
|
query,
|
||||||
modifier: sortMod,
|
type: 'best_fields',
|
||||||
factor: 1.2
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (sort === 'recent' && !isUrlSearch) {
|
osQuery.function_score.query.bool.should = [...termQueries, ...subquery]
|
||||||
// prioritize exact matches
|
osQuery.function_score.query.bool.minimum_should_match = 1
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.length) {
|
// use hybrid neural search if model id is available, otherwise use only
|
||||||
// if we have a model id and we aren't sort by recent, use neural search
|
// keyword search
|
||||||
if (process.env.OPENSEARCH_MODEL_ID && sort !== 'recent') {
|
if (process.env.OPENSEARCH_MODEL_ID) {
|
||||||
termQueries = {
|
osQuery = {
|
||||||
hybrid: {
|
hybrid: {
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
@ -345,30 +424,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
filter: filters,
|
||||||
|
minimum_should_match: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
osQuery
|
||||||
bool: {
|
|
||||||
should: termQueries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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 {
|
try {
|
||||||
@ -384,45 +449,7 @@ export default {
|
|||||||
},
|
},
|
||||||
from: decodedCursor.offset,
|
from: decodedCursor.offset,
|
||||||
body: {
|
body: {
|
||||||
query: {
|
query: osQuery,
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
highlight: {
|
highlight: {
|
||||||
fields: {
|
fields: {
|
||||||
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
||||||
@ -458,7 +485,7 @@ export default {
|
|||||||
${SELECT}, rank
|
${SELECT}, rank
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN r ON "Item".id = r.id`,
|
JOIN r ON "Item".id = r.id`,
|
||||||
orderBy: 'ORDER BY rank ASC'
|
orderBy: 'ORDER BY rank ASC, msats DESC'
|
||||||
})).map((item, i) => {
|
})).map((item, i) => {
|
||||||
const e = sitems.body.hits.hits[i]
|
const e = sitems.body.hits.hits[i]
|
||||||
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
|
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
|
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,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
|
||||||
ed-kung,issue,#1952,#1951,easy,,,,10k,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 { UserListRow } from '@/components/user-list'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AddIcon from '@/svgs/add-fill.svg'
|
import AddIcon from '@/svgs/add-fill.svg'
|
||||||
|
import { MultiAuthErrorBanner } from '@/components/banners'
|
||||||
|
|
||||||
const AccountContext = createContext()
|
const AccountContext = createContext()
|
||||||
|
|
||||||
|
const CHECK_ERRORS_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
|
||||||
|
|
||||||
const maybeSecureCookie = cookie => {
|
const maybeSecureCookie = cookie => {
|
||||||
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
|
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AccountProvider = ({ children }) => {
|
export const AccountProvider = ({ children }) => {
|
||||||
const { me } = useMe()
|
|
||||||
const [accounts, setAccounts] = useState([])
|
const [accounts, setAccounts] = useState([])
|
||||||
const [meAnon, setMeAnon] = useState(true)
|
const [meAnon, setMeAnon] = useState(true)
|
||||||
|
const [errors, setErrors] = useState([])
|
||||||
|
|
||||||
const updateAccountsFromCookie = useCallback(() => {
|
const updateAccountsFromCookie = useCallback(() => {
|
||||||
try {
|
|
||||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||||
const accounts = multiAuthCookie
|
const accounts = multiAuthCookie
|
||||||
? JSON.parse(b64Decode(multiAuthCookie))
|
? JSON.parse(b64Decode(multiAuthCookie))
|
||||||
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
|
: []
|
||||||
setAccounts(accounts)
|
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)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(updateAccountsFromCookie, [])
|
|
||||||
|
|
||||||
const addAccount = useCallback(user => {
|
const addAccount = useCallback(user => {
|
||||||
setAccounts(accounts => [...accounts, user])
|
setAccounts(accounts => [...accounts, user])
|
||||||
}, [])
|
}, [])
|
||||||
@ -50,7 +41,7 @@ export const AccountProvider = ({ children }) => {
|
|||||||
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
|
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const multiAuthSignout = useCallback(async () => {
|
const nextAccount = useCallback(async () => {
|
||||||
const { status } = await fetch('/api/next-account', { credentials: 'include' })
|
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
|
// 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.
|
// 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
|
return switchSuccess
|
||||||
}, [updateAccountsFromCookie])
|
}, [updateAccountsFromCookie])
|
||||||
|
|
||||||
useEffect(() => {
|
const checkErrors = useCallback(() => {
|
||||||
if (SSR) return
|
const {
|
||||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
multi_auth: multiAuthCookie,
|
||||||
setMeAnon(multiAuthUserIdCookie === 'anonymous')
|
'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(
|
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>
|
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,9 +148,23 @@ const AccountListRow = ({ account, ...props }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SwitchAccountList () {
|
export default function SwitchAccountList () {
|
||||||
const { accounts } = useAccounts()
|
const { accounts, multiAuthErrors } = useAccounts()
|
||||||
const router = useRouter()
|
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
|
// can't show hat since the streak is not included in the JWT payload
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -6,6 +6,7 @@ import { useMutation } from '@apollo/client'
|
|||||||
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
||||||
import { useToast } from '@/components/toast'
|
import { useToast } from '@/components/toast'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import AccordianItem from '@/components/accordian-item'
|
||||||
|
|
||||||
export function WelcomeBanner ({ Banner }) {
|
export function WelcomeBanner ({ Banner }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
@ -123,3 +124,24 @@ export function AuthBanner () {
|
|||||||
</Alert>
|
</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 { signIn } from 'next-auth/react'
|
||||||
import styles from './login.module.css'
|
import styles from './login.module.css'
|
||||||
import { Form, Input, SubmitButton } from '@/components/form'
|
import { Form, Input, SubmitButton } from '@/components/form'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Alert from 'react-bootstrap/Alert'
|
import Alert from 'react-bootstrap/Alert'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { LightningAuthWithExplainer } from './lightning-auth'
|
import { LightningAuthWithExplainer } from './lightning-auth'
|
||||||
@ -42,10 +42,10 @@ const authErrorMessages = {
|
|||||||
OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.',
|
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.',
|
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.',
|
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.',
|
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.',
|
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.'
|
default: 'Auth failed. Try again or choose a different method.'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,10 +53,23 @@ export function authErrorMessage (error) {
|
|||||||
return error && (authErrorMessages[error] ?? authErrorMessages.default)
|
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 [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
|
||||||
const router = useRouter()
|
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') {
|
if (router.query.type === 'lightning') {
|
||||||
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
|
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
|
||||||
}
|
}
|
||||||
@ -112,6 +125,7 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
|
|||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
|
key={provider.id}
|
||||||
placement='bottom'
|
placement='bottom'
|
||||||
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
|
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
|
||||||
trigger={['hover', 'focus']}
|
trigger={['hover', 'focus']}
|
||||||
@ -119,7 +133,6 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
|
|||||||
<div className='w-100'>
|
<div className='w-100'>
|
||||||
<LoginButton
|
<LoginButton
|
||||||
className={`mt-2 ${styles.providerButton}`}
|
className={`mt-2 ${styles.providerButton}`}
|
||||||
key={provider.id}
|
|
||||||
type={provider.id.toLowerCase()}
|
type={provider.id.toLowerCase()}
|
||||||
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
|
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
|
||||||
text={`${text || 'Login'} with`}
|
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 }) {
|
export function SignUpButton ({ className = 'py-0', width }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const handleLogin = useCallback(async pathname => await router.push({
|
const handleLogin = useCallback(async pathname => await router.push({
|
||||||
@ -233,7 +236,8 @@ export function SignUpButton ({ className = 'py-0', width }) {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={classNames('align-items-center ps-2 pe-3', className)}
|
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'
|
id='signup'
|
||||||
onClick={() => handleLogin('/signup')}
|
onClick={() => handleLogin('/signup')}
|
||||||
>
|
>
|
||||||
@ -257,7 +261,7 @@ export default function LoginButton () {
|
|||||||
<Button
|
<Button
|
||||||
className='align-items-center px-3 py-1'
|
className='align-items-center px-3 py-1'
|
||||||
id='login'
|
id='login'
|
||||||
style={{ borderWidth: '2px', width: '150px' }}
|
style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
|
||||||
variant='outline-grey-darkmode'
|
variant='outline-grey-darkmode'
|
||||||
onClick={() => handleLogin('/login')}
|
onClick={() => handleLogin('/login')}
|
||||||
>
|
>
|
||||||
@ -269,7 +273,7 @@ export default function LoginButton () {
|
|||||||
function LogoutObstacle ({ onClose }) {
|
function LogoutObstacle ({ onClose }) {
|
||||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||||
const { removeLocalWallets } = useWallets()
|
const { removeLocalWallets } = useWallets()
|
||||||
const { multiAuthSignout } = useAccounts()
|
const { nextAccount } = useAccounts()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -285,9 +289,9 @@ function LogoutObstacle ({ onClose }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const switchSuccess = await multiAuthSignout()
|
const next = await nextAccount()
|
||||||
// only signout if multiAuth did not find a next available account
|
// only signout if we did not find a next account
|
||||||
if (switchSuccess) {
|
if (next) {
|
||||||
onClose()
|
onClose()
|
||||||
// reload whatever page we're on to avoid any bugs
|
// reload whatever page we're on to avoid any bugs
|
||||||
router.reload()
|
router.reload()
|
||||||
@ -344,7 +348,7 @@ function SwitchAccountButton ({ handleClose }) {
|
|||||||
<Button
|
<Button
|
||||||
className='align-items-center px-3 py-1'
|
className='align-items-center px-3 py-1'
|
||||||
variant='outline-grey-darkmode'
|
variant='outline-grey-darkmode'
|
||||||
style={{ borderWidth: '2px', width: '150px' }}
|
style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
|
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
|
||||||
// so we manually close the offcanvas in that case by passing down handleClose here
|
// 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}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
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}
|
{pullMessage}
|
||||||
</p>
|
</p>
|
||||||
{children}
|
{children}
|
||||||
|
@ -36,7 +36,7 @@ export default function Search ({ sub }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (values.what === '' || values.what === 'all') delete values.what
|
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 === '' || values.when === 'forever') delete values.when
|
||||||
if (values.when !== 'custom') { delete values.from; delete values.to }
|
if (values.when !== 'custom') { delete values.from; delete values.to }
|
||||||
if (values.from && !values.to) return
|
if (values.from && !values.to) return
|
||||||
@ -50,7 +50,7 @@ export default function Search ({ sub }) {
|
|||||||
|
|
||||||
const filter = sub !== 'jobs'
|
const filter = sub !== 'jobs'
|
||||||
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all'
|
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 when = router.query.when || 'forever'
|
||||||
const whatItemOptions = useMemo(() => (['all', 'posts', 'comments', me ? 'bookmarks' : undefined, 'stackers'].filter(item => !!item)), [me])
|
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'
|
name='sort'
|
||||||
size='sm'
|
size='sm'
|
||||||
overrideValue={sort}
|
overrideValue={sort}
|
||||||
items={['zaprank', 'recent', 'comments', 'sats']}
|
items={['relevance', 'zaprank', 'recent', 'comments', 'sats']}
|
||||||
/>
|
/>
|
||||||
for
|
for
|
||||||
<Select
|
<Select
|
||||||
|
@ -94,8 +94,19 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// we're currently on the home sub
|
// we're currently on the home sub
|
||||||
|
// 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?
|
// are we in a sub aware route?
|
||||||
if (router.pathname.startsWith('/~')) {
|
|
||||||
// if we are, go to the same path but in the sub
|
// if we are, go to the same path but in the sub
|
||||||
asPath = `/~${sub}` + router.asPath
|
asPath = `/~${sub}` + router.asPath
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,7 +4,7 @@ import Image from 'react-bootstrap/Image'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Nav from 'react-bootstrap/Nav'
|
import Nav from 'react-bootstrap/Nav'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Form, Input, SubmitButton } from './form'
|
import { Form, Input, SubmitButton } from './form'
|
||||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
import styles from './user-header.module.css'
|
import styles from './user-header.module.css'
|
||||||
@ -199,8 +199,14 @@ export function NymActionDropdown ({ user, className = 'ms-2' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HeaderNym ({ user, isMe }) {
|
function HeaderNym ({ user, isMe }) {
|
||||||
|
const router = useRouter()
|
||||||
const [editting, setEditting] = useState(false)
|
const [editting, setEditting] = useState(false)
|
||||||
|
|
||||||
|
// if route changes, reset editting state
|
||||||
|
useEffect(() => {
|
||||||
|
setEditting(false)
|
||||||
|
}, [router.asPath])
|
||||||
|
|
||||||
return editting
|
return editting
|
||||||
? <NymEdit user={user} setEditting={setEditting} />
|
? <NymEdit user={user} setEditting={setEditting} />
|
||||||
: <NymView user={user} isMe={isMe} setEditting={setEditting} />
|
: <NymView user={user} isMe={isMe} setEditting={setEditting} />
|
||||||
|
@ -62,7 +62,11 @@ function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
|
|||||||
const { deleteLogs } = useWalletLogManager(setLogs)
|
const { deleteLogs } = useWalletLogManager(setLogs)
|
||||||
const toaster = useToast()
|
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 (
|
return (
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
{prompt}
|
{prompt}
|
||||||
|
@ -163,7 +163,7 @@ services:
|
|||||||
- "CONNECT=localhost:4566"
|
- "CONNECT=localhost:4566"
|
||||||
cpu_shares: "${CPU_SHARES_LOW}"
|
cpu_shares: "${CPU_SHARES_LOW}"
|
||||||
opensearch:
|
opensearch:
|
||||||
image: opensearchproject/opensearch:2.12.0
|
image: opensearchproject/opensearch:2.17.0
|
||||||
container_name: opensearch
|
container_name: opensearch
|
||||||
profiles:
|
profiles:
|
||||||
- search
|
- search
|
||||||
@ -203,7 +203,7 @@ services:
|
|||||||
'
|
'
|
||||||
cpu_shares: "${CPU_SHARES_LOW}"
|
cpu_shares: "${CPU_SHARES_LOW}"
|
||||||
os-dashboard:
|
os-dashboard:
|
||||||
image: opensearchproject/opensearch-dashboards:2.12.0
|
image: opensearchproject/opensearch-dashboards:2.17.0
|
||||||
container_name: os-dashboard
|
container_name: os-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
|
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,
|
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(
|
return new Date(
|
||||||
date.getFullYear() + years,
|
date.getFullYear() + years,
|
||||||
date.getMonth() + months,
|
date.getMonth() + months,
|
||||||
date.getDate() + days,
|
date.getDate() + days + weeks * 7,
|
||||||
date.getHours() + hours,
|
date.getHours() + hours,
|
||||||
date.getMinutes() + minutes,
|
date.getMinutes() + minutes,
|
||||||
date.getSeconds() + seconds,
|
date.getSeconds() + seconds,
|
||||||
|
@ -194,6 +194,21 @@ module.exports = withPlausibleProxy()({
|
|||||||
source: '/top/cowboys/:when',
|
source: '/top/cowboys/:when',
|
||||||
destination: '/top/cowboys',
|
destination: '/top/cowboys',
|
||||||
permanent: true
|
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 nodemailer from 'nodemailer'
|
||||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||||
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
|
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
|
||||||
import { datePivot } from '@/lib/time'
|
|
||||||
import { schnorr } from '@noble/curves/secp256k1'
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
import { notifyReferral } from '@/lib/webPush'
|
import { notifyReferral } from '@/lib/webPush'
|
||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
import * as cookie from 'cookie'
|
import { multiAuthMiddleware, setMultiAuthCookies } from '@/lib/auth'
|
||||||
import { multiAuthMiddleware } from '@/pages/api/graphql'
|
|
||||||
import { BECH32_CHARSET } from '@/lib/constants'
|
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
|
* Stores userIds in user table
|
||||||
@ -94,6 +94,8 @@ function getCallbacks (req, res) {
|
|||||||
*/
|
*/
|
||||||
async jwt ({ token, user, account, profile, isNewUser }) {
|
async jwt ({ token, user, account, profile, isNewUser }) {
|
||||||
if (user) {
|
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
|
// token won't have an id on it for new logins, we add it
|
||||||
// note: token is what's kept in the jwt
|
// note: token is what's kept in the jwt
|
||||||
token.id = Number(user.id)
|
token.id = Number(user.id)
|
||||||
@ -124,8 +126,8 @@ function getCallbacks (req, res) {
|
|||||||
token.sub = Number(token.id)
|
token.sub = Number(token.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// add multi_auth cookie for user that just logged in
|
|
||||||
if (user && req && res) {
|
if (user && req && res) {
|
||||||
|
// add multi_auth cookie for user that just logged in
|
||||||
const secret = process.env.NEXTAUTH_SECRET
|
const secret = process.env.NEXTAUTH_SECRET
|
||||||
const jwt = await encodeJWT({ token, secret })
|
const jwt = await encodeJWT({ token, secret })
|
||||||
const me = await prisma.user.findUnique({ where: { id: token.id } })
|
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) {
|
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||||
const { k1, pubkey } = credentials
|
const { k1, pubkey } = credentials
|
||||||
|
|
||||||
@ -194,7 +165,7 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
|||||||
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
|
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
|
||||||
|
|
||||||
// make following code aware of cookie pointer for account switching
|
// 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
|
// token will be undefined if we're not logged in at all or if we switched to anon
|
||||||
const token = await getToken({ req })
|
const token = await getToken({ req })
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -205,7 +176,8 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
|||||||
if (token?.id && !multiAuth) {
|
if (token?.id && !multiAuth) {
|
||||||
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
|
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
|
||||||
} else {
|
} 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 } })
|
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -314,6 +286,7 @@ export const getAuthOptions = (req, res) => ({
|
|||||||
adapter: {
|
adapter: {
|
||||||
...PrismaAdapter(prisma),
|
...PrismaAdapter(prisma),
|
||||||
createUser: data => {
|
createUser: data => {
|
||||||
|
if (req.cookies.signin) return null
|
||||||
// replace email with email hash in new user payload
|
// replace email with email hash in new user payload
|
||||||
if (data.email) {
|
if (data.email) {
|
||||||
const { email } = data
|
const { email } = data
|
||||||
@ -754,7 +727,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -769,7 +742,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -779,7 +752,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
ApolloServerPluginLandingPageLocalDefault,
|
ApolloServerPluginLandingPageLocalDefault,
|
||||||
ApolloServerPluginLandingPageProductionDefault
|
ApolloServerPluginLandingPageProductionDefault
|
||||||
} from '@apollo/server/plugin/landingPage/default'
|
} from '@apollo/server/plugin/landingPage/default'
|
||||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
import { multiAuthMiddleware } from '@/lib/auth'
|
||||||
|
|
||||||
const apolloServer = new ApolloServer({
|
const apolloServer = new ApolloServer({
|
||||||
typeDefs,
|
typeDefs,
|
||||||
@ -68,7 +68,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
|
|||||||
session = { user: { ...sessionFields, apiKey: true } }
|
session = { user: { ...sessionFields, apiKey: true } }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
req = multiAuthMiddleware(req)
|
req = await multiAuthMiddleware(req, res)
|
||||||
session = await getServerSession(req, res, getAuthOptions(req))
|
session = await getServerSession(req, res, getAuthOptions(req))
|
||||||
}
|
}
|
||||||
return {
|
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
|
<Login
|
||||||
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
|
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
|
||||||
Header={() => <LoginHeader />}
|
Header={() => <LoginHeader />}
|
||||||
|
signin
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</StaticLayout>
|
</StaticLayout>
|
||||||
|
@ -131,8 +131,6 @@ function Enabled ({ setVaultKey, clearVault }) {
|
|||||||
placeholder=''
|
placeholder=''
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
as='textarea'
|
|
||||||
rows={3}
|
|
||||||
qr
|
qr
|
||||||
/>
|
/>
|
||||||
<div className='mt-3'>
|
<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)
|
directReceive Boolean @default(true)
|
||||||
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
|
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
|
||||||
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
|
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
|
||||||
|
UserSubTrust UserSubTrust[]
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -184,6 +185,21 @@ model OneDayReferral {
|
|||||||
@@index([type, typeId])
|
@@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 {
|
enum WalletType {
|
||||||
LIGHTNING_ADDRESS
|
LIGHTNING_ADDRESS
|
||||||
LND
|
LND
|
||||||
@ -520,6 +536,7 @@ model Item {
|
|||||||
status Status @default(ACTIVE)
|
status Status @default(ACTIVE)
|
||||||
company String?
|
company String?
|
||||||
weightedVotes Float @default(0)
|
weightedVotes Float @default(0)
|
||||||
|
subWeightedVotes Float @default(0)
|
||||||
boost Int @default(0)
|
boost Int @default(0)
|
||||||
oldBoost Int @default(0)
|
oldBoost Int @default(0)
|
||||||
pollCost Int?
|
pollCost Int?
|
||||||
@ -534,6 +551,7 @@ model Item {
|
|||||||
mcredits BigInt @default(0)
|
mcredits BigInt @default(0)
|
||||||
cost Int @default(0)
|
cost Int @default(0)
|
||||||
weightedDownVotes Float @default(0)
|
weightedDownVotes Float @default(0)
|
||||||
|
subWeightedDownVotes Float @default(0)
|
||||||
bio Boolean @default(false)
|
bio Boolean @default(false)
|
||||||
freebie Boolean @default(false)
|
freebie Boolean @default(false)
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
@ -760,6 +778,7 @@ model Sub {
|
|||||||
MuteSub MuteSub[]
|
MuteSub MuteSub[]
|
||||||
SubSubscription SubSubscription[]
|
SubSubscription SubSubscription[]
|
||||||
TerritoryTransfer TerritoryTransfer[]
|
TerritoryTransfer TerritoryTransfer[]
|
||||||
|
UserSubTrust UserSubTrust[]
|
||||||
|
|
||||||
@@index([parentName])
|
@@index([parentName])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@ -33,7 +33,7 @@ export async function createInvoice (
|
|||||||
out: false
|
out: false
|
||||||
})
|
})
|
||||||
|
|
||||||
let hostname = url.replace(/^https?:\/\//, '')
|
let hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||||
const agent = getAgent({ hostname })
|
const agent = getAgent({ hostname })
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production' && hostname.startsWith('localhost:')) {
|
if (process.env.NODE_ENV !== 'production' && hostname.startsWith('localhost:')) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { fetchWithTimeout } from '@/lib/fetch'
|
import { fetchWithTimeout } from '@/lib/fetch'
|
||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
|
import { getAgent } from '@/lib/proxy'
|
||||||
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
|
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
|
||||||
|
|
||||||
export * from '@/wallets/phoenixd'
|
export * from '@/wallets/phoenixd'
|
||||||
@ -27,9 +28,13 @@ export async function createInvoice (
|
|||||||
body.append('description', description)
|
body.append('description', description)
|
||||||
body.append('amountSat', msatsToSats(msats))
|
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',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
|
agent,
|
||||||
body,
|
body,
|
||||||
signal
|
signal
|
||||||
})
|
})
|
||||||
|
@ -14,6 +14,7 @@ export async function expireBoost ({ data: { id }, models }) {
|
|||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
WHERE act = 'BOOST'
|
WHERE act = 'BOOST'
|
||||||
AND "itemId" = ${Number(id)}::INTEGER
|
AND "itemId" = ${Number(id)}::INTEGER
|
||||||
|
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
|
||||||
)
|
)
|
||||||
UPDATE "Item"
|
UPDATE "Item"
|
||||||
SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000
|
SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000
|
||||||
|
270
worker/trust.js
270
worker/trust.js
@ -1,38 +1,89 @@
|
|||||||
import * as math from 'mathjs'
|
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 }) {
|
const MAX_DEPTH = 40
|
||||||
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_TRUST = 1
|
const MAX_TRUST = 1
|
||||||
const MIN_SUCCESS = 1
|
const MIN_SUCCESS = 0
|
||||||
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
|
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
|
||||||
const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
|
const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
|
||||||
const GLOBAL_ROOT = 616
|
const SEED_WEIGHT = 0.83
|
||||||
const SEED_WEIGHT = 1.0
|
|
||||||
const AGAINST_MSAT_MIN = 1000
|
const AGAINST_MSAT_MIN = 1000
|
||||||
const MSAT_MIN = 20001 // 20001 is the minimum for a tip to be counted in trust
|
const MSAT_MIN = 1001 // 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 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
|
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
|
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
|
// 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
|
// create a map of user id to position in matrix
|
||||||
const posByUserId = {}
|
const posByUserId = {}
|
||||||
@ -54,54 +105,57 @@ function trustGivenGraph (graph) {
|
|||||||
|
|
||||||
// perform random walk over trust matrix
|
// perform random walk over trust matrix
|
||||||
// the resulting matrix columns represent the trust a user (col) has for each other user (rows)
|
// 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
|
const matT = math.transpose(mat)
|
||||||
let matT = math.transpose(mat)
|
const vTrust = math.zeros(graph.length)
|
||||||
const original = matT.clone()
|
for (const seed of seeds) {
|
||||||
for (let i = 0; i < MAX_DEPTH; i++) {
|
vTrust.set([posByUserId[seed], 0], 1.0 / seeds.length)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
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')
|
console.timeLog('trust', 'transforming result')
|
||||||
|
|
||||||
const seedIdxs = SN_ADMIN_IDS.map(id => posByUserId[id])
|
const seedIdxs = seeds.map(id => posByUserId[id])
|
||||||
const isOutlier = (fromIdx, idx) => [...seedIdxs, fromIdx].includes(idx)
|
const filterZeroAndSeed = (val, idx) => {
|
||||||
const sqapply = (mat, fn) => {
|
return val !== 0 && !seedIdxs.includes(idx[0])
|
||||||
let idx = 0
|
}
|
||||||
return math.squeeze(math.apply(mat, 1, d => {
|
const filterSeed = (val, idx) => {
|
||||||
const filtered = math.filter(d, (val, fidx) => {
|
return !seedIdxs.includes(idx[0])
|
||||||
return val !== 0 && !isOutlier(idx, fidx[0])
|
}
|
||||||
})
|
const sqapply = (vec, filterFn, fn) => {
|
||||||
idx++
|
// if the vector is smaller than the seeds, don't filter
|
||||||
if (filtered.length === 0) return 0
|
const filtered = vec.size()[0] > seeds.length ? math.filter(vec, filterFn) : vec
|
||||||
|
if (filtered.size()[0] === 0) return 0
|
||||||
return fn(filtered)
|
return fn(filtered)
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.timeLog('trust', 'normalizing')
|
console.timeLog('trust', 'normalizing')
|
||||||
console.timeLog('trust', 'stats')
|
console.timeLog('trust', 'stats')
|
||||||
mat = math.transpose(matT)
|
const std = sqapply(result, filterZeroAndSeed, math.std) // math.squeeze(math.std(mat, 1))
|
||||||
const std = sqapply(mat, math.std) // math.squeeze(math.std(mat, 1))
|
const mean = sqapply(result, filterZeroAndSeed, math.mean) // math.squeeze(math.mean(mat, 1))
|
||||||
const mean = sqapply(mat, math.mean) // math.squeeze(math.mean(mat, 1))
|
console.timeLog('trust', 'std', std)
|
||||||
const zscore = math.map(mat, (val, idx) => {
|
console.timeLog('trust', 'mean', mean)
|
||||||
const zstd = math.subset(std, math.index(idx[0], 0))
|
const zscore = math.map(result, (val) => {
|
||||||
const zmean = math.subset(mean, math.index(idx[0], 0))
|
if (std === 0) return 0
|
||||||
return zstd ? (val - zmean) / zstd : 0
|
return (val - mean) / std
|
||||||
})
|
})
|
||||||
console.timeLog('trust', 'minmax')
|
console.timeLog('trust', 'minmax')
|
||||||
const min = sqapply(zscore, math.min) // math.squeeze(math.min(zscore, 1))
|
const min = sqapply(zscore, filterSeed, math.min) // math.squeeze(math.min(zscore, 1))
|
||||||
const max = sqapply(zscore, math.max) // math.squeeze(math.max(zscore, 1))
|
const max = sqapply(zscore, filterSeed, math.max) // math.squeeze(math.max(zscore, 1))
|
||||||
const mPersonal = math.map(zscore, (val, idx) => {
|
console.timeLog('trust', 'min', min)
|
||||||
const zmin = math.subset(min, math.index(idx[0], 0))
|
console.timeLog('trust', 'max', max)
|
||||||
const zmax = math.subset(max, math.index(idx[0], 0))
|
const normalized = math.map(zscore, (val) => {
|
||||||
const zrange = zmax - zmin
|
const zrange = max - min
|
||||||
if (val > zmax) return MAX_TRUST
|
if (val > max) return MAX_TRUST
|
||||||
return zrange ? (val - zmin) / zrange : 0
|
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`
|
return await models.$queryRaw`
|
||||||
SELECT id, json_agg(json_build_object(
|
SELECT id, json_agg(json_build_object(
|
||||||
'node', oid,
|
'node', oid,
|
||||||
'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops
|
'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops
|
||||||
FROM (
|
FROM (
|
||||||
WITH user_votes AS (
|
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,
|
users.created_at AS user_at, "ItemAct".act = 'DONT_LIKE_THIS' AS against,
|
||||||
count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count,
|
count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count,
|
||||||
sum("ItemAct".msats) as user_msats
|
sum("ItemAct".msats) as user_msats
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
|
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}
|
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
|
||||||
WHERE "ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'
|
WHERE ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
|
||||||
GROUP BY user_id, name, item_id, user_at, against
|
GROUP BY user_id, users.name, item_id, user_at, against
|
||||||
HAVING CASE WHEN
|
HAVING CASE WHEN
|
||||||
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
|
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
|
||||||
ELSE sum("ItemAct".msats) > ${MSAT_MIN} END
|
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,
|
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 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(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
|
b.user_vote_count AS b_total, a.user_vote_count AS a_total
|
||||||
FROM user_votes a
|
FROM user_votes a
|
||||||
JOIN user_votes b ON a.item_id = b.item_id
|
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})
|
confidence(before - disagree, b_total - after, ${Z_CONFIDENCE})
|
||||||
ELSE 0 END AS trust
|
ELSE 0 END AS trust
|
||||||
FROM user_pair
|
FROM user_pair
|
||||||
WHERE NOT (b_id = ANY (${SN_ADMIN_IDS}))
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust
|
SELECT seed_id AS id, seed_id AS oid, 0 AS trust
|
||||||
FROM user_pair, unnest(${SN_ADMIN_IDS}::int[]) seed_id
|
FROM unnest(${seeds}::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 id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust
|
SELECT id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust
|
||||||
FROM trust_pairs
|
FROM trust_pairs
|
||||||
@ -165,46 +222,45 @@ async function getGraph (models) {
|
|||||||
ORDER BY id ASC`
|
ORDER BY id ASC`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeTrust (models, graph, vGlobal, mPersonal) {
|
function reduceVectors (subName, fieldGraphVectors) {
|
||||||
// convert nodeTrust into table literal string
|
function reduceVector (field, graph, vector, result = {}) {
|
||||||
let globalValues = ''
|
vector.forEach((val, [idx]) => {
|
||||||
let personalValues = ''
|
if (isNaN(val) || val <= 0) return
|
||||||
vGlobal.forEach((val, [idx]) => {
|
result[graph[idx].id] = {
|
||||||
if (isNaN(val)) return
|
...result[graph[idx].id],
|
||||||
if (globalValues) globalValues += ','
|
subName,
|
||||||
globalValues += `(${graph[idx].id}, ${val}::FLOAT)`
|
userId: graph[idx].id,
|
||||||
if (personalValues) personalValues += ','
|
[field]: val
|
||||||
personalValues += `(${GLOBAL_ROOT}, ${graph[idx].id}, ${val}::FLOAT)`
|
}
|
||||||
})
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
math.forEach(mPersonal, (val, [fromIdx, toIdx]) => {
|
let result = {}
|
||||||
const globalVal = vGlobal.get([toIdx, 0])
|
for (const field in fieldGraphVectors) {
|
||||||
if (isNaN(val) || val - globalVal <= SIG_DIFF) return
|
result = reduceVector(field, fieldGraphVectors[field].graph, fieldGraphVectors[field].vector, result)
|
||||||
if (personalValues) personalValues += ','
|
}
|
||||||
personalValues += `(${graph[fromIdx].id}, ${graph[toIdx].id}, ${val}::FLOAT)`
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// 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
|
// update the trust of each user in graph
|
||||||
await models.$transaction([
|
await models.$transaction([
|
||||||
models.$executeRaw`UPDATE users SET trust = 0`,
|
models.userSubTrust.deleteMany({
|
||||||
models.$executeRawUnsafe(
|
where: {
|
||||||
`UPDATE users
|
subName
|
||||||
SET trust = g.trust
|
}
|
||||||
FROM (values ${globalValues}) g(id, trust)
|
}),
|
||||||
WHERE users.id = g.id`),
|
models.userSubTrust.createMany({
|
||||||
models.$executeRawUnsafe(
|
data: results
|
||||||
`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)
|
|
||||||
)`
|
|
||||||
)
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,7 @@ export async function rankViews () {
|
|||||||
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
|
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const view of ['zap_rank_personal_view']) {
|
for (const view of ['hot_score_view']) {
|
||||||
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user