a6713f9793
* WIP: Account switching * Fix empty USER query ANON_USER_ID was undefined and thus the query for @anon had no variables. * Apply multiAuthMiddleware in /api/graphql * Fix 'you must be logged in' query error on switch to anon * Add smart 'switch account' button "smart" means that it only shows if there are accounts to which one can switch * Fix multiAuth not set in backend * Comment fixes, minor changes * Use fw-bold instead of 'selected' * Close dropdown and offcanvas Inside a dropdown, we can rely on autoClose but need to wrap the buttons with <Dropdown.Item> for that to work. For the offcanvas, we need to pass down handleClose. * Use button to add account * Some pages require hard reload on account switch * Reinit settings form on account switch * Also don't refetch WalletHistory * Formatting * Use width: fit-content for standalone SignUpButton * Remove unused className * Use fw-bold and text-underline on selected * Fix inconsistent padding of login buttons * Fix duplicate redirect from /settings on anon switch * Never throw during refetch * Throw errors which extend GraphQLError * Only use meAnonSats if logged out * Use reactive variable for meAnonSats The previous commit broke the UI update after anon zaps because we actually updated item.meSats in the cache and not item.meAnonSats. Updating item.meAnonSats was not possible because it's a local field. For that, one needs to use reactive variables. We do this now and thus also don't need the useEffect hack in item-info.js anymore. * Switch to new user * Fix missing cleanup during logout If we logged in but never switched to any other account, the 'multi_auth.user-id' cookie was not set. This meant that during logout, the other 'multi_auth.*' cookies were not deleted. This broke the account switch modal. This is fixed by setting the 'multi_auth.user-id' cookie on login. Additionally, we now cleanup if cookie pointer OR session is set (instead of only if both are set). * Fix comments in middleware * Remove unnecessary effect dependencies setState is stable and thus only noise in effect dependencies * Show but disable unavailable auth methods * make signup button consistent with others * Always reload page on switch * refine account switch styling * logout barrier --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
1143 lines
36 KiB
JavaScript
1143 lines
36 KiB
JavaScript
import { readFile } from 'fs/promises'
|
|
import { join, resolve } from 'path'
|
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
|
import { msatsToSats } from '@/lib/format'
|
|
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
|
|
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
|
|
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
|
|
import { viewGroup } from './growth'
|
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
|
import assertApiKeyNotPermitted from './apiKey'
|
|
import { hashEmail } from '@/lib/crypto'
|
|
import { isMuted } from '@/lib/user'
|
|
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
|
|
|
const contributors = new Set()
|
|
|
|
const loadContributors = async (set) => {
|
|
try {
|
|
const fileContent = await readFile(resolve(join(process.cwd(), 'contributors.txt')), 'utf-8')
|
|
fileContent.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => !!line)
|
|
.forEach(name => set.add(name))
|
|
} catch (err) {
|
|
console.error('Error loading contributors', err)
|
|
}
|
|
}
|
|
|
|
async function authMethods (user, args, { models, me }) {
|
|
if (!me || me.id !== user.id) {
|
|
return {
|
|
lightning: false,
|
|
twitter: false,
|
|
github: false,
|
|
nostr: false
|
|
}
|
|
}
|
|
|
|
const accounts = await models.account.findMany({
|
|
where: {
|
|
userId: me.id
|
|
}
|
|
})
|
|
|
|
const oauth = accounts.map(a => a.provider)
|
|
|
|
return {
|
|
lightning: !!user.pubkey,
|
|
email: !!(user.emailVerified && user.emailHash),
|
|
twitter: oauth.indexOf('twitter') >= 0,
|
|
github: oauth.indexOf('github') >= 0,
|
|
nostr: !!user.nostrAuthPubkey,
|
|
apiKey: user.apiKeyEnabled ? !!user.apiKeyHash : null
|
|
}
|
|
}
|
|
|
|
export async function topUsers (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) {
|
|
const decodedCursor = decodeCursor(cursor)
|
|
const range = whenRange(when, from, to || decodeCursor.time)
|
|
|
|
let column
|
|
switch (by) {
|
|
case 'spending':
|
|
case 'spent': column = 'spent'; break
|
|
case 'posts': column = 'nposts'; break
|
|
case 'comments': column = 'ncomments'; break
|
|
case 'referrals': column = 'referrals'; break
|
|
case 'stacking': column = 'stacked'; break
|
|
default: column = 'proportion'; break
|
|
}
|
|
|
|
const users = (await models.$queryRawUnsafe(`
|
|
SELECT *
|
|
FROM
|
|
(SELECT users.*,
|
|
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
|
COALESCE(sum(posts), 0) as nposts,
|
|
COALESCE(sum(comments), 0) as ncomments,
|
|
COALESCE(sum(referrals), 0) as referrals,
|
|
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
JOIN users on users.id = u.id
|
|
GROUP BY users.id) uu
|
|
${column === 'proportion' ? `JOIN ${viewValueGroup()} ON uu.id = vv.id` : ''}
|
|
ORDER BY ${column} DESC NULLS LAST, uu.created_at ASC
|
|
OFFSET $3
|
|
LIMIT $4`, ...range, decodedCursor.offset, limit)
|
|
).map(
|
|
u => u.hideFromTopUsers && (!me || me.id !== u.id) ? null : u
|
|
)
|
|
|
|
return {
|
|
cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
|
users
|
|
}
|
|
}
|
|
|
|
export function viewValueGroup () {
|
|
return `(
|
|
SELECT v.id, sum(proportion) as proportion
|
|
FROM (
|
|
(SELECT *
|
|
FROM user_values_days
|
|
WHERE user_values_days.t >= date_trunc('day', timezone('America/Chicago', $1))
|
|
AND date_trunc('day', user_values_days.t) <= date_trunc('day', timezone('America/Chicago', $2)))
|
|
UNION ALL
|
|
(SELECT * FROM
|
|
user_values_today
|
|
WHERE user_values_today.t >= date_trunc('day', timezone('America/Chicago', $1))
|
|
AND date_trunc('day', user_values_today.t) <= date_trunc('day', timezone('America/Chicago', $2)))
|
|
) v
|
|
WHERE v.id NOT IN (${SN_NO_REWARDS_IDS.join(',')})
|
|
GROUP BY v.id
|
|
) vv`
|
|
}
|
|
|
|
export default {
|
|
Query: {
|
|
me: async (parent, args, { models, me }) => {
|
|
if (!me?.id) {
|
|
return null
|
|
}
|
|
|
|
return await models.user.findUnique({ where: { id: me.id } })
|
|
},
|
|
settings: async (parent, args, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await models.user.findUnique({ where: { id: me.id } })
|
|
},
|
|
user: async (parent, { id, name }, { models }) => {
|
|
if (id) id = Number(id)
|
|
return await models.user.findUnique({ where: { id, name } })
|
|
},
|
|
users: async (parent, args, { models }) =>
|
|
await models.user.findMany(),
|
|
nameAvailable: async (parent, { name }, { models, me }) => {
|
|
let user
|
|
if (me) {
|
|
user = await models.user.findUnique({ where: { id: me.id } })
|
|
}
|
|
return user?.name?.toUpperCase() === name?.toUpperCase() || !(await models.user.findUnique({ where: { name } }))
|
|
},
|
|
mySubscribedUsers: async (parent, { cursor }, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const decodedCursor = decodeCursor(cursor)
|
|
const users = await models.$queryRaw`
|
|
SELECT users.*
|
|
FROM "UserSubscription"
|
|
JOIN users ON "UserSubscription"."followeeId" = users.id
|
|
WHERE "UserSubscription"."followerId" = ${me.id}
|
|
AND ("UserSubscription"."postsSubscribedAt" IS NOT NULL OR "UserSubscription"."commentsSubscribedAt" IS NOT NULL)
|
|
OFFSET ${decodedCursor.offset}
|
|
LIMIT ${LIMIT}
|
|
`
|
|
|
|
return {
|
|
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
|
users
|
|
}
|
|
},
|
|
myMutedUsers: async (parent, { cursor }, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const decodedCursor = decodeCursor(cursor)
|
|
const users = await models.$queryRaw`
|
|
SELECT users.*
|
|
FROM "Mute"
|
|
JOIN users ON "Mute"."mutedId" = users.id
|
|
WHERE "Mute"."muterId" = ${me.id}
|
|
OFFSET ${decodedCursor.offset}
|
|
LIMIT ${LIMIT}
|
|
`
|
|
|
|
return {
|
|
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
|
users
|
|
}
|
|
},
|
|
topCowboys: async (parent, { cursor }, { models, me }) => {
|
|
const decodedCursor = decodeCursor(cursor)
|
|
const range = whenRange('forever')
|
|
|
|
const users = (await models.$queryRawUnsafe(`
|
|
SELECT users.*,
|
|
coalesce(floor(sum(msats_spent)/1000),0) as spent,
|
|
coalesce(sum(posts),0) as nposts,
|
|
coalesce(sum(comments),0) as ncomments,
|
|
coalesce(sum(referrals),0) as referrals,
|
|
coalesce(floor(sum(msats_stacked)/1000),0) as stacked
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
JOIN users on users.id = u.id
|
|
WHERE streak IS NOT NULL
|
|
GROUP BY users.id
|
|
ORDER BY streak DESC, created_at ASC
|
|
OFFSET $3
|
|
LIMIT ${LIMIT}`, ...range, decodedCursor.offset)
|
|
).map(
|
|
u => (u.hideFromTopUsers || u.hideCowboyHat) && (!me || me.id !== u.id) ? null : u
|
|
)
|
|
|
|
return {
|
|
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
|
users
|
|
}
|
|
},
|
|
userSuggestions: async (parent, { q, limit = 5 }, { models }) => {
|
|
let users = []
|
|
if (q) {
|
|
users = await models.$queryRaw`
|
|
SELECT name
|
|
FROM users
|
|
WHERE (
|
|
id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete})
|
|
)
|
|
AND SIMILARITY(name, ${q}) > 0.1
|
|
ORDER BY SIMILARITY(name, ${q}) DESC
|
|
LIMIT ${limit}`
|
|
} else {
|
|
users = await models.$queryRaw`
|
|
SELECT name
|
|
FROM user_stats_days
|
|
JOIN users on users.id = user_stats_days.id
|
|
WHERE NOT users."hideFromTopUsers"
|
|
AND user_stats_days.t = (SELECT max(t) FROM user_stats_days)
|
|
ORDER BY msats_stacked DESC, users.created_at ASC
|
|
LIMIT ${limit}`
|
|
}
|
|
|
|
return users
|
|
},
|
|
topUsers,
|
|
hasNewNotes: async (parent, args, { me, models }) => {
|
|
if (!me) {
|
|
return false
|
|
}
|
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
|
const lastChecked = user.checkedNotesAt || new Date(0)
|
|
|
|
// if we've already recorded finding notes after they last checked, return true
|
|
// this saves us from rechecking notifications
|
|
if (user.foundNotesAt > lastChecked) {
|
|
return true
|
|
}
|
|
|
|
const foundNotes = () =>
|
|
models.user.update({
|
|
where: { id: me.id },
|
|
data: {
|
|
foundNotesAt: new Date(),
|
|
lastSeenAt: new Date()
|
|
}
|
|
}).catch(console.error)
|
|
|
|
// check if any votes have been cast for them since checkedNotesAt
|
|
if (user.noteItemSats) {
|
|
const [newSats] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "Item"
|
|
WHERE "Item"."lastZapAt" > $2
|
|
AND "Item"."userId" = $1)`, me.id, lastChecked)
|
|
if (newSats.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
// break out thread subscription to decrease the search space of the already expensive reply query
|
|
const [newThreadSubReply] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "ThreadSubscription"
|
|
JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId"
|
|
JOIN "Item" ON r."itemId" = "Item".id
|
|
${whereClause(
|
|
'"ThreadSubscription"."userId" = $1',
|
|
'r.created_at > $2',
|
|
'r.created_at >= "ThreadSubscription".created_at',
|
|
'r."userId" <> $1',
|
|
activeOrMine(me),
|
|
await filterClause(me, models),
|
|
muteClause(me),
|
|
...(user.noteAllDescendants ? [] : ['r.level = 1'])
|
|
)})`, me.id, lastChecked)
|
|
if (newThreadSubReply.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
const [newUserSubs] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "UserSubscription"
|
|
JOIN "Item" ON "UserSubscription"."followeeId" = "Item"."userId"
|
|
${whereClause(
|
|
'"UserSubscription"."followerId" = $1',
|
|
'"Item".created_at > $2',
|
|
`(
|
|
("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt")
|
|
OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt")
|
|
)`,
|
|
activeOrMine(me),
|
|
await filterClause(me, models),
|
|
muteClause(me))})`, me.id, lastChecked)
|
|
if (newUserSubs.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
const [newSubPost] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "SubSubscription"
|
|
JOIN "Item" ON "SubSubscription"."subName" = "Item"."subName"
|
|
${whereClause(
|
|
'"SubSubscription"."userId" = $1',
|
|
'"Item".created_at > $2',
|
|
'"Item"."parentId" IS NULL',
|
|
'"Item"."userId" <> $1',
|
|
activeOrMine(me),
|
|
await filterClause(me, models),
|
|
muteClause(me))})`, me.id, lastChecked)
|
|
if (newSubPost.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
// check if they have any mentions since checkedNotesAt
|
|
if (user.noteMentions) {
|
|
const [newMentions] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "Mention"
|
|
JOIN "Item" ON "Mention"."itemId" = "Item".id
|
|
${whereClause(
|
|
'"Mention"."userId" = $1',
|
|
'"Mention".created_at > $2',
|
|
'"Item"."userId" <> $1',
|
|
activeOrMine(me),
|
|
await filterClause(me, models),
|
|
muteClause(me)
|
|
)})`, me.id, lastChecked)
|
|
if (newMentions.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (user.noteItemMentions) {
|
|
const [newMentions] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "ItemMention"
|
|
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
|
|
JOIN "Item" ON "ItemMention"."referrerId" = "Item".id
|
|
${whereClause(
|
|
'"ItemMention".created_at > $2',
|
|
'"Item"."userId" <> $1',
|
|
'"Referee"."userId" = $1',
|
|
activeOrMine(me),
|
|
await filterClause(me, models),
|
|
muteClause(me)
|
|
)})`, me.id, lastChecked)
|
|
if (newMentions.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (user.noteForwardedSats) {
|
|
const [newFwdSats] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM "Item"
|
|
JOIN "ItemForward" ON
|
|
"ItemForward"."itemId" = "Item".id
|
|
AND "ItemForward"."userId" = $1
|
|
${whereClause(
|
|
'"Item"."lastZapAt" > $2',
|
|
'"Item"."userId" <> $1',
|
|
activeOrMine(me),
|
|
await filterClause(me, models),
|
|
muteClause(me)
|
|
)})`, me.id, lastChecked)
|
|
if (newFwdSats.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
const job = await models.item.findFirst({
|
|
where: {
|
|
maxBid: {
|
|
not: null
|
|
},
|
|
userId: me.id,
|
|
statusUpdatedAt: {
|
|
gt: lastChecked
|
|
}
|
|
}
|
|
})
|
|
if (job && job.statusUpdatedAt > job.createdAt) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
if (user.noteEarning) {
|
|
const earn = await models.earn.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
createdAt: {
|
|
gt: lastChecked
|
|
},
|
|
msats: {
|
|
gte: 1000
|
|
}
|
|
}
|
|
})
|
|
if (earn) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (user.noteDeposits) {
|
|
const invoice = await models.invoice.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
confirmedAt: {
|
|
gt: lastChecked
|
|
},
|
|
isHeld: null,
|
|
actionType: null
|
|
}
|
|
})
|
|
if (invoice) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (user.noteWithdrawals) {
|
|
const wdrwl = await models.withdrawl.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
status: 'CONFIRMED',
|
|
updatedAt: {
|
|
gt: lastChecked
|
|
}
|
|
}
|
|
})
|
|
if (wdrwl) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
// check if new invites have been redeemed
|
|
if (user.noteInvites) {
|
|
const [newInvites] = await models.$queryRawUnsafe(`
|
|
SELECT EXISTS(
|
|
SELECT *
|
|
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
|
|
WHERE "Invite"."userId" = $1
|
|
AND users.created_at > $2)`, me.id, lastChecked)
|
|
if (newInvites.exists) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
const referral = await models.user.findFirst({
|
|
where: {
|
|
referrerId: me.id,
|
|
createdAt: {
|
|
gt: lastChecked
|
|
}
|
|
}
|
|
})
|
|
if (referral) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (user.noteCowboyHat) {
|
|
const streak = await models.streak.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
updatedAt: {
|
|
gt: lastChecked
|
|
}
|
|
}
|
|
})
|
|
|
|
if (streak) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
const subStatus = await models.sub.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
statusUpdatedAt: {
|
|
gt: lastChecked
|
|
},
|
|
status: {
|
|
not: 'ACTIVE'
|
|
}
|
|
}
|
|
})
|
|
|
|
if (subStatus) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
const newReminder = await models.reminder.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
remindAt: {
|
|
gt: lastChecked,
|
|
lt: new Date()
|
|
}
|
|
}
|
|
})
|
|
if (newReminder) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
const invoiceActionFailed = await models.invoice.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
updatedAt: {
|
|
gt: lastChecked
|
|
},
|
|
actionType: {
|
|
in: INVOICE_ACTION_NOTIFICATION_TYPES
|
|
},
|
|
actionState: 'FAILED'
|
|
}
|
|
})
|
|
|
|
if (invoiceActionFailed) {
|
|
foundNotes()
|
|
return true
|
|
}
|
|
|
|
// update checkedNotesAt to prevent rechecking same time period
|
|
models.user.update({
|
|
where: { id: me.id },
|
|
data: {
|
|
checkedNotesAt: new Date(),
|
|
lastSeenAt: new Date()
|
|
}
|
|
}).catch(console.error)
|
|
|
|
return false
|
|
},
|
|
searchUsers: async (parent, { q, limit, similarity }, { models }) => {
|
|
return await models.$queryRaw`
|
|
SELECT *
|
|
FROM users
|
|
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}))
|
|
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
|
},
|
|
userStatsActions: async (parent, { when, from, to }, { me, models }) => {
|
|
const range = whenRange(when, from, to)
|
|
return await models.$queryRawUnsafe(`
|
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
|
json_build_array(
|
|
json_build_object('name', 'comments', 'value', COALESCE(SUM(comments), 0)),
|
|
json_build_object('name', 'posts', 'value', COALESCE(SUM(posts), 0)),
|
|
json_build_object('name', 'territories', 'value', COALESCE(SUM(territories), 0)),
|
|
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0)),
|
|
json_build_object('name', 'one day referrals', 'value', COALESCE(SUM(one_day_referrals), 0))
|
|
) AS data
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
WHERE id = ${me.id}
|
|
GROUP BY time
|
|
ORDER BY time ASC`, ...range)
|
|
},
|
|
userStatsIncomingSats: async (parent, { when, from, to }, { me, models }) => {
|
|
const range = whenRange(when, from, to)
|
|
return await models.$queryRawUnsafe(`
|
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
|
json_build_array(
|
|
json_build_object('name', 'zaps', 'value', ROUND(COALESCE(SUM(msats_tipped), 0) / 1000)),
|
|
json_build_object('name', 'rewards', 'value', ROUND(COALESCE(SUM(msats_rewards), 0) / 1000)),
|
|
json_build_object('name', 'referrals', 'value', ROUND( COALESCE(SUM(msats_referrals), 0) / 1000)),
|
|
json_build_object('name', 'one day referrals', 'value', ROUND( COALESCE(SUM(msats_one_day_referrals), 0) / 1000)),
|
|
json_build_object('name', 'territories', 'value', ROUND(COALESCE(SUM(msats_revenue), 0) / 1000))
|
|
) AS data
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
WHERE id = ${me.id}
|
|
GROUP BY time
|
|
ORDER BY time ASC`, ...range)
|
|
},
|
|
userStatsOutgoingSats: async (parent, { when, from, to }, { me, models }) => {
|
|
const range = whenRange(when, from, to)
|
|
return await models.$queryRawUnsafe(`
|
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
|
json_build_array(
|
|
json_build_object('name', 'fees', 'value', FLOOR(COALESCE(SUM(msats_fees), 0) / 1000)),
|
|
json_build_object('name', 'zapping', 'value', FLOOR(COALESCE(SUM(msats_zaps), 0) / 1000)),
|
|
json_build_object('name', 'donations', 'value', FLOOR(COALESCE(SUM(msats_donated), 0) / 1000)),
|
|
json_build_object('name', 'territories', 'value', FLOOR(COALESCE(SUM(msats_billing), 0) / 1000))
|
|
) AS data
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
WHERE id = ${me.id}
|
|
GROUP BY time
|
|
ORDER BY time ASC`, ...range)
|
|
}
|
|
},
|
|
|
|
Mutation: {
|
|
disableFreebies: async (parent, args, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
// disable freebies if it hasn't been set yet
|
|
try {
|
|
await models.user.update({
|
|
where: { id: me.id, disableFreebies: null },
|
|
data: { disableFreebies: true }
|
|
})
|
|
} catch (err) {
|
|
// ignore 'record not found' errors
|
|
if (err.code !== 'P2025') {
|
|
throw err
|
|
}
|
|
}
|
|
|
|
return true
|
|
},
|
|
setName: async (parent, data, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await ssValidate(userSchema, data, { models })
|
|
|
|
try {
|
|
await models.user.update({ where: { id: me.id }, data })
|
|
return data.name
|
|
} catch (error) {
|
|
if (error.code === 'P2002') {
|
|
throw new GqlInputError('name taken')
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await ssValidate(settingsSchema, { nostrRelays, ...data })
|
|
|
|
if (nostrRelays?.length) {
|
|
const connectOrCreate = []
|
|
for (const nr of nostrRelays) {
|
|
await models.nostrRelay.upsert({
|
|
where: { addr: nr },
|
|
update: { addr: nr },
|
|
create: { addr: nr }
|
|
})
|
|
connectOrCreate.push({
|
|
where: { userId_nostrRelayAddr: { userId: me.id, nostrRelayAddr: nr } },
|
|
create: { nostrRelayAddr: nr }
|
|
})
|
|
}
|
|
|
|
return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {}, connectOrCreate } } })
|
|
} else {
|
|
return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {} } } })
|
|
}
|
|
},
|
|
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } })
|
|
|
|
return true
|
|
},
|
|
setPhoto: async (parent, { photoId }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await models.user.update({
|
|
where: { id: me.id },
|
|
data: { photoId: Number(photoId) }
|
|
})
|
|
|
|
return Number(photoId)
|
|
},
|
|
upsertBio: async (parent, { bio }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await ssValidate(bioSchema, { bio })
|
|
|
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
|
|
|
if (user.bioId) {
|
|
await updateItem(parent, { id: user.bioId, text: bio, title: `@${user.name}'s bio` }, { me, models })
|
|
} else {
|
|
await createItem(parent, { bio: true, text: bio, title: `@${user.name}'s bio` }, { me, models })
|
|
}
|
|
|
|
return await models.user.findUnique({ where: { id: me.id } })
|
|
},
|
|
generateApiKey: async (parent, { id }, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
|
if (!user.apiKeyEnabled) {
|
|
throw new GqlAuthorizationError('you are not allowed to generate api keys')
|
|
}
|
|
|
|
// I trust postgres CSPRNG more than the one from JS
|
|
const [{ apiKey, apiKeyHash }] = await models.$queryRaw`
|
|
SELECT "apiKey", encode(digest("apiKey", 'sha256'), 'hex') AS "apiKeyHash"
|
|
FROM (
|
|
SELECT encode(gen_random_bytes(32), 'base64')::CHAR(32) as "apiKey"
|
|
) rng`
|
|
await models.user.update({ where: { id: me.id }, data: { apiKeyHash } })
|
|
|
|
return apiKey
|
|
},
|
|
deleteApiKey: async (parent, { id }, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await models.user.update({ where: { id: me.id }, data: { apiKeyHash: null } })
|
|
},
|
|
unlinkAuth: async (parent, { authType }, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
assertApiKeyNotPermitted({ me })
|
|
|
|
let user
|
|
if (authType === 'twitter' || authType === 'github') {
|
|
user = await models.user.findUnique({ where: { id: me.id } })
|
|
const account = await models.account.findFirst({ where: { userId: me.id, provider: authType } })
|
|
if (!account) {
|
|
throw new GqlInputError('no such account')
|
|
}
|
|
await models.account.delete({ where: { id: account.id } })
|
|
if (authType === 'twitter') {
|
|
await models.user.update({ where: { id: me.id }, data: { hideTwitter: true, twitterId: null } })
|
|
} else {
|
|
await models.user.update({ where: { id: me.id }, data: { hideGithub: true, githubId: null } })
|
|
}
|
|
} else if (authType === 'lightning') {
|
|
user = await models.user.update({ where: { id: me.id }, data: { pubkey: null } })
|
|
} else if (authType === 'nostr') {
|
|
user = await models.user.update({ where: { id: me.id }, data: { hideNostr: true, nostrAuthPubkey: null } })
|
|
} else if (authType === 'email') {
|
|
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null, emailHash: null } })
|
|
} else {
|
|
throw new GqlInputError('no such account')
|
|
}
|
|
|
|
return await authMethods(user, undefined, { models, me })
|
|
},
|
|
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
assertApiKeyNotPermitted({ me })
|
|
|
|
await ssValidate(emailSchema, { email })
|
|
|
|
try {
|
|
await models.user.update({
|
|
where: { id: me.id },
|
|
data: { emailHash: hashEmail({ email }) }
|
|
})
|
|
} catch (error) {
|
|
if (error.code === 'P2002') {
|
|
throw new GqlInputError('email taken')
|
|
}
|
|
throw error
|
|
}
|
|
|
|
return true
|
|
},
|
|
subscribeUserPosts: async (parent, { id }, { me, models }) => {
|
|
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
|
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
|
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
|
if (existing) {
|
|
if (muted && !existing.postsSubscribedAt) {
|
|
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
|
|
}
|
|
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
|
|
} else {
|
|
if (muted) {
|
|
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
|
|
}
|
|
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
|
|
}
|
|
return { id }
|
|
},
|
|
subscribeUserComments: async (parent, { id }, { me, models }) => {
|
|
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
|
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
|
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
|
if (existing) {
|
|
if (muted && !existing.commentsSubscribedAt) {
|
|
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
|
|
}
|
|
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
|
|
} else {
|
|
if (muted) {
|
|
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
|
|
}
|
|
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
|
|
}
|
|
return { id }
|
|
},
|
|
toggleMute: async (parent, { id }, { me, models }) => {
|
|
const lookupData = { muterId: Number(me.id), mutedId: Number(id) }
|
|
const where = { muterId_mutedId: lookupData }
|
|
const existing = await models.mute.findUnique({ where })
|
|
if (existing) {
|
|
await models.mute.delete({ where })
|
|
} else {
|
|
// check to see if current user is subscribed to the target user, and disallow mute if so
|
|
const subscription = await models.userSubscription.findUnique({
|
|
where: {
|
|
followerId_followeeId: {
|
|
followerId: Number(me.id),
|
|
followeeId: Number(id)
|
|
}
|
|
}
|
|
})
|
|
if (subscription?.postsSubscribedAt || subscription?.commentsSubscribedAt) {
|
|
throw new GqlInputError("you can't mute a stacker to whom you've subscribed")
|
|
}
|
|
await models.mute.create({ data: { ...lookupData } })
|
|
}
|
|
return { id }
|
|
},
|
|
hideWelcomeBanner: async (parent, data, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
|
|
return true
|
|
}
|
|
},
|
|
|
|
User: {
|
|
privates: async (user, args, { me, models }) => {
|
|
if (!me || me.id !== user.id) {
|
|
return null
|
|
}
|
|
|
|
return user
|
|
},
|
|
optional: user => user,
|
|
meSubscriptionPosts: async (user, args, { me, models }) => {
|
|
if (!me) return false
|
|
if (typeof user.meSubscriptionPosts !== 'undefined') return user.meSubscriptionPosts
|
|
|
|
const subscription = await models.userSubscription.findUnique({
|
|
where: {
|
|
followerId_followeeId: {
|
|
followerId: Number(me.id),
|
|
followeeId: Number(user.id)
|
|
}
|
|
}
|
|
})
|
|
|
|
return !!subscription?.postsSubscribedAt
|
|
},
|
|
meSubscriptionComments: async (user, args, { me, models }) => {
|
|
if (!me) return false
|
|
if (typeof user.meSubscriptionComments !== 'undefined') return user.meSubscriptionComments
|
|
|
|
const subscription = await models.userSubscription.findUnique({
|
|
where: {
|
|
followerId_followeeId: {
|
|
followerId: Number(me.id),
|
|
followeeId: Number(user.id)
|
|
}
|
|
}
|
|
})
|
|
|
|
return !!subscription?.commentsSubscribedAt
|
|
},
|
|
meMute: async (user, args, { me, models }) => {
|
|
if (!me) return false
|
|
if (typeof user.meMute !== 'undefined') return user.meMute
|
|
|
|
return await isMuted({ models, muterId: me.id, mutedId: user.id })
|
|
},
|
|
since: async (user, args, { models }) => {
|
|
// get the user's first item
|
|
const item = await models.item.findFirst({
|
|
where: {
|
|
userId: user.id
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc'
|
|
}
|
|
})
|
|
return item?.id
|
|
},
|
|
nitems: async (user, { when, from, to }, { models }) => {
|
|
if (typeof user.nitems !== 'undefined') {
|
|
return user.nitems
|
|
}
|
|
|
|
const [gte, lte] = whenRange(when, from, to)
|
|
return await models.item.count({
|
|
where: {
|
|
userId: user.id,
|
|
createdAt: {
|
|
gte,
|
|
lte
|
|
}
|
|
}
|
|
})
|
|
},
|
|
nposts: async (user, { when, from, to }, { models }) => {
|
|
if (typeof user.nposts !== 'undefined') {
|
|
return user.nposts
|
|
}
|
|
|
|
const [gte, lte] = whenRange(when, from, to)
|
|
return await models.item.count({
|
|
where: {
|
|
userId: user.id,
|
|
parentId: null,
|
|
createdAt: {
|
|
gte,
|
|
lte
|
|
}
|
|
}
|
|
})
|
|
},
|
|
ncomments: async (user, { when, from, to }, { models }) => {
|
|
if (typeof user.ncomments !== 'undefined') {
|
|
return user.ncomments
|
|
}
|
|
|
|
const [gte, lte] = whenRange(when, from, to)
|
|
return await models.item.count({
|
|
where: {
|
|
userId: user.id,
|
|
parentId: { not: null },
|
|
createdAt: {
|
|
gte,
|
|
lte
|
|
}
|
|
}
|
|
})
|
|
},
|
|
nterritories: async (user, { when, from, to }, { models }) => {
|
|
if (typeof user.nterritories !== 'undefined') {
|
|
return user.nterritories
|
|
}
|
|
|
|
const [gte, lte] = whenRange(when, from, to)
|
|
return await models.sub.count({
|
|
where: {
|
|
userId: user.id,
|
|
status: 'ACTIVE',
|
|
createdAt: {
|
|
gte,
|
|
lte
|
|
}
|
|
}
|
|
})
|
|
},
|
|
bio: async (user, args, { models, me }) => {
|
|
return getItem(user, { id: user.bioId }, { models, me })
|
|
}
|
|
},
|
|
|
|
UserPrivates: {
|
|
sats: async (user, args, { models, me }) => {
|
|
if (!me || me.id !== user.id) {
|
|
return 0
|
|
}
|
|
return msatsToSats(user.msats)
|
|
},
|
|
authMethods,
|
|
hasInvites: async (user, args, { models }) => {
|
|
const invites = await models.user.findUnique({
|
|
where: { id: user.id }
|
|
}).invites({ take: 1 })
|
|
|
|
return invites.length > 0
|
|
},
|
|
nostrRelays: async (user, args, { models, me }) => {
|
|
if (user.id !== me.id) {
|
|
return []
|
|
}
|
|
|
|
const relays = await models.userNostrRelay.findMany({
|
|
where: { userId: user.id }
|
|
})
|
|
|
|
return relays?.map(r => r.nostrRelayAddr)
|
|
},
|
|
tipRandom: async (user, args, { me }) => {
|
|
if (!me || me.id !== user.id) {
|
|
return false
|
|
}
|
|
return !!user.tipRandomMin && !!user.tipRandomMax
|
|
}
|
|
},
|
|
|
|
UserOptional: {
|
|
streak: async (user, args, { models }) => {
|
|
if (user.hideCowboyHat) {
|
|
return null
|
|
}
|
|
|
|
return user.streak
|
|
},
|
|
maxStreak: async (user, args, { models }) => {
|
|
if (user.hideCowboyHat) {
|
|
return null
|
|
}
|
|
|
|
const [{ max }] = await models.$queryRaw`
|
|
SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
|
|
FROM "Streak" WHERE "userId" = ${user.id}`
|
|
return max
|
|
},
|
|
isContributor: async (user, args, { me }) => {
|
|
// lazy init contributors only once
|
|
if (contributors.size === 0) {
|
|
await loadContributors(contributors)
|
|
}
|
|
if (me?.id === user.id) {
|
|
return contributors.has(user.name)
|
|
}
|
|
return !user.hideIsContributor && contributors.has(user.name)
|
|
},
|
|
stacked: async (user, { when, from, to }, { models, me }) => {
|
|
if ((!me || me.id !== user.id) && user.hideFromTopUsers) {
|
|
return null
|
|
}
|
|
|
|
if (typeof user.stacked !== 'undefined') {
|
|
return user.stacked
|
|
}
|
|
|
|
if (!when || when === 'forever') {
|
|
// forever
|
|
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
|
|
}
|
|
|
|
const range = whenRange(when, from, to)
|
|
const [{ stacked }] = await models.$queryRawUnsafe(`
|
|
SELECT sum(msats_stacked) as stacked
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
WHERE id = $3`, ...range, Number(user.id))
|
|
return (stacked && msatsToSats(stacked)) || 0
|
|
},
|
|
spent: async (user, { when, from, to }, { models, me }) => {
|
|
if ((!me || me.id !== user.id) && user.hideFromTopUsers) {
|
|
return null
|
|
}
|
|
|
|
if (typeof user.spent !== 'undefined') {
|
|
return user.spent
|
|
}
|
|
|
|
const range = whenRange(when, from, to)
|
|
const [{ spent }] = await models.$queryRawUnsafe(`
|
|
SELECT sum(msats_spent) as spent
|
|
FROM ${viewGroup(range, 'user_stats')}
|
|
WHERE id = $3`, ...range, Number(user.id))
|
|
|
|
return (spent && msatsToSats(spent)) || 0
|
|
},
|
|
referrals: async (user, { when, from, to }, { models, me }) => {
|
|
if ((!me || me.id !== user.id) && user.hideFromTopUsers) {
|
|
return null
|
|
}
|
|
|
|
if (typeof user.referrals !== 'undefined') {
|
|
return user.referrals
|
|
}
|
|
|
|
const [gte, lte] = whenRange(when, from, to)
|
|
return await models.user.count({
|
|
where: {
|
|
referrerId: user.id,
|
|
createdAt: {
|
|
gte,
|
|
lte
|
|
}
|
|
}
|
|
})
|
|
},
|
|
githubId: async (user, args, { me }) => {
|
|
if ((!me || me.id !== user.id) && user.hideGithub) {
|
|
return null
|
|
}
|
|
return user.githubId
|
|
},
|
|
twitterId: async (user, args, { models, me }) => {
|
|
if ((!me || me.id !== user.id) && user.hideTwitter) {
|
|
return null
|
|
}
|
|
return user.twitterId
|
|
},
|
|
nostrAuthPubkey: async (user, args, { models, me }) => {
|
|
if ((!me || me.id !== user.id) && user.hideNostr) {
|
|
return null
|
|
}
|
|
return user.nostrAuthPubkey
|
|
}
|
|
}
|
|
}
|