import { readFile } from 'fs/promises' import { join, resolve } from 'path' import { GraphQLError } from 'graphql' 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' 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 GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } return await models.user.findUnique({ where: { id: me.id } }) }, user: async (parent, { name }, { models }) => { return await models.user.findUnique({ where: { 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 GraphQLError('You must be logged in to view subscribed users', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('You must be logged in to view muted users', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } // disable freebies if it hasn't been set yet await models.user.update({ where: { id: me.id, disableFreebies: null }, data: { disableFreebies: true } }) return true }, setName: async (parent, data, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) } throw error } }, setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } }) return true }, setPhoto: async (parent, { photoId }, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } const user = await models.user.findUnique({ where: { id: me.id } }) if (!user.apiKeyEnabled) { throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } }) } // 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 GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } return await models.user.update({ where: { id: me.id }, data: { apiKeyHash: null } }) }, unlinkAuth: async (parent, { authType }, { models, me }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) } 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 GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) } return await authMethods(user, undefined, { models, me }) }, linkUnverifiedEmail: async (parent, { email }, { models, me }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } }) } 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 GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } }) } await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } }) } else { if (muted) { throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } }) } 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 GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } }) } await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } }) } else { if (muted) { throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } }) } 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 GraphQLError("you can't mute a stacker to whom you've subscribed", { extensions: { code: 'BAD_INPUT' } }) } await models.mute.create({ data: { ...lookupData } }) } return { id } }, hideWelcomeBanner: async (parent, data, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 } } }