From b379e7467f431431cfd77ae55d58888356127ed1 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 5 Mar 2024 20:56:02 +0100 Subject: [PATCH] Territory transfers (#878) * Allow founders to transfer territories * Log territory transfers in new AuditLog table * Add territory transfer notifications * Use polymorphic AuditEvent table * Add setting for territory transfer notifications * Add push notification * Rename label from user to stacker * More space between cancel and confirm button * Remove AuditEvent table The audit table is not necessary for territory transfers and only adds complexity and unrelated discussion to this PR. Thinking about a future-proof schema for territory transfers and how/what to audit at the same time made my head spin. Some thoughts I had: 1. Maybe using polymorphism for an audit log / audit events is not a good idea Using polymorphism as is currently used in the code base (user wallets) means that every generic event must map to exactly one specialized event. Is this a good requirement/assumption? It already didn't work well for naive auditing of territory transfers since we want events to be indexable by user (no array column) so every event needs to point to a single user but a territory transfer involves multiple users. This made me wonder: Do we even need a table? Maybe the audit log for a user can be implemented using a view? This would also mean no data denormalization. 2. What to audit and how and why? Most actions are already tracked in some way by necessity: zaps, items, mutes, payments, ... In that case: what is the benefit of tracking these things individually in a separate table? Denormalize simply for convenience or performance? Why no view (see previous point)? Use case needs to be more clearly defined before speccing out a schema. * Fix territory transfer notification id conflict * Use include instead of two separate queries * Drop territory transfer setting * Remove trigger usage * Prevent transfers to yourself --- api/resolvers/notifications.js | 17 +++ api/resolvers/sub.js | 35 ++++++ api/typeDefs/notifications.js | 9 +- api/typeDefs/sub.js | 1 + components/notifications.js | 15 ++- components/territory-header.js | 11 +- components/territory-transfer.js | 100 ++++++++++++++++++ fragments/notifications.js | 7 ++ lib/push-notifications.js | 11 ++ lib/validate.js | 19 ++++ .../migration.sql | 25 +++++ prisma/schema.prisma | 17 +++ sw/eventListener.js | 3 +- 13 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 components/territory-transfer.js create mode 100644 prisma/migrations/20240305143404_territory_transfer/migration.sql diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 36d83658..4850aa29 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -168,6 +168,17 @@ export default { LIMIT ${LIMIT}+$3)` ) + // territory transfers + queries.push( + `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats", + 'TerritoryTransfer' AS type + FROM "TerritoryTransfer" + WHERE "TerritoryTransfer"."newUserId" = $1 + AND "TerritoryTransfer"."created_at" <= $2 + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}+$3)` + ) + if (meFull.noteItemSats) { queries.push( `(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime", @@ -367,6 +378,12 @@ export default { TerritoryPost: { item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) }, + TerritoryTransfer: { + sub: async (n, args, { models, me }) => { + const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } }) + return transfer.sub + } + }, JobChanged: { item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) }, diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 9d06c618..ead446c9 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -6,6 +6,7 @@ import { ssValidate, territorySchema } from '../../lib/validate' import { nextBilling, proratedBillingCost } from '../../lib/territory' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { subViewGroup } from './growth' +import { notifyTerritoryTransfer } from '../../lib/push-notifications' export function paySubQueries (sub, models) { if (sub.billingType === 'ONCE') { @@ -280,6 +281,40 @@ export default { await models.subSubscription.create({ data: lookupData }) return true } + }, + transferTerritory: async (parent, { subName, userName }, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + } + + const sub = await models.sub.findUnique({ + where: { + name: subName + } + }) + if (!sub) { + throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } }) + } + if (sub.userId !== me.id) { + throw new GraphQLError('you do not own this sub', { extensions: { code: 'BAD_INPUT' } }) + } + + const user = await models.user.findFirst({ where: { name: userName } }) + if (!user) { + throw new GraphQLError('user not found', { extensions: { code: 'BAD_INPUT' } }) + } + if (user.id === me.id) { + throw new GraphQLError('cannot transfer territory to yourself', { extensions: { code: 'BAD_INPUT' } }) + } + + const [, updatedSub] = await models.$transaction([ + models.territoryTransfer.create({ data: { subName, oldUserId: me.id, newUserId: user.id } }), + models.sub.update({ where: { name: subName }, data: { userId: user.id } }) + ]) + + notifyTerritoryTransfer({ models, sub, to: user }) + + return updatedSub } }, Sub: { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index ed1c73f7..07d94701 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -108,9 +108,16 @@ export default gql` sortTime: Date! } + type TerritoryTransfer { + id: ID! + sub: Sub! + sortTime: Date! + } + union Notification = Reply | Votification | Mention | Invitification | Earn | JobChanged | InvoicePaid | Referral - | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus | TerritoryPost + | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus + | TerritoryPost | TerritoryTransfer type Notifications { lastChecked: Date diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 99b6e39b..806f139d 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -22,6 +22,7 @@ export default gql` paySub(name: String!, hash: String, hmac: String): Sub toggleMuteSub(name: String!): Boolean! toggleSubSubscription(name: String!): Boolean! + transferTerritory(subName: String!, userName: String!): Sub } type Sub { diff --git a/components/notifications.js b/components/notifications.js index 4683ba1d..a767c0aa 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -49,7 +49,8 @@ function Notification ({ n, fresh }) { (type === 'Reply' && ) || (type === 'SubStatus' && ) || (type === 'FollowActivity' && ) || - (type === 'TerritoryPost' && ) + (type === 'TerritoryPost' && ) || + (type === 'TerritoryTransfer' && ) } ) @@ -96,6 +97,7 @@ const defaultOnClick = n => { if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` } if (type === 'Referral') return { href: '/referrals/month' } if (type === 'Streak') return {} + if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } // Votification, Mention, JobChanged, Reply all have item if (!n.item.title) { @@ -426,6 +428,17 @@ function TerritoryPost ({ n }) { ) } +function TerritoryTransfer ({ n }) { + return ( + <> +
+ ~{n.sub.name} was transferred to you + {timeSince(new Date(n.sortTime))} +
+ + ) +} + export function NotificationAlert () { const [showAlert, setShowAlert] = useState(false) const [hasSubscription, setHasSubscription] = useState(false) diff --git a/components/territory-header.js b/components/territory-header.js index 5f9ed124..83aa47b4 100644 --- a/components/territory-header.js +++ b/components/territory-header.js @@ -11,6 +11,7 @@ import Share from './share' import { gql, useMutation } from '@apollo/client' import { useToast } from './toast' import ActionDropdown from './action-dropdown' +import { TerritoryTransferDropdownItem } from './territory-transfer' export function TerritoryDetails ({ sub }) { return ( @@ -72,6 +73,8 @@ export default function TerritoryHeader ({ sub }) { } ) + const isMine = Number(sub.userId) === Number(me?.id) + return ( <> @@ -83,7 +86,7 @@ export default function TerritoryHeader ({ sub }) { {me && <> - {(Number(sub.userId) === Number(me?.id) + {(isMine ? ( @@ -106,6 +109,12 @@ export default function TerritoryHeader ({ sub }) { )} + {isMine && ( + <> + + + + )} } diff --git a/components/territory-transfer.js b/components/territory-transfer.js new file mode 100644 index 00000000..3533cc01 --- /dev/null +++ b/components/territory-transfer.js @@ -0,0 +1,100 @@ +import { gql, useApolloClient, useMutation } from '@apollo/client' +import { useShowModal } from './modal' +import { useToast } from './toast' +import { Button, Dropdown, InputGroup } from 'react-bootstrap' +import { Form, InputUserSuggest, SubmitButton } from './form' +import { territoryTransferSchema } from '../lib/validate' +import { useCallback } from 'react' +import Link from 'next/link' +import { useMe } from './me' + +function TransferObstacle ({ sub, onClose, userName }) { + const toaster = useToast() + const [transfer] = useMutation( + gql` + mutation transferTerritory($subName: String!, $userName: String!) { + transferTerritory(subName: $subName, userName: $userName) { + name + user { + id + } + } + } + ` + ) + + return ( +
+ Do you really want to transfer your territory +
+ ~{sub.name} + {' '}to{' '} + @{userName}? +
+
+ + +
+
+ ) +} + +function TerritoryTransferForm ({ sub, onClose }) { + const showModal = useShowModal() + const client = useApolloClient() + const me = useMe() + const schema = territoryTransferSchema({ me, client }) + + const onSubmit = useCallback(async (values) => { + showModal(onClose => ) + }, []) + + return ( +
+

transfer territory

+
+ @} + showValid + autoFocus + /> +
+ transfer +
+ ) +} + +export function TerritoryTransferDropdownItem ({ sub }) { + const showModal = useShowModal() + return ( + + showModal(onClose => + )} + > + transfer + + ) +} diff --git a/fragments/notifications.js b/fragments/notifications.js index a291384a..247506da 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -94,6 +94,13 @@ export const NOTIFICATIONS = gql` text } } + ... on TerritoryTransfer { + id + sortTime + sub { + ...SubFields + } + } ... on Invitification { id sortTime diff --git a/lib/push-notifications.js b/lib/push-notifications.js index 4ffaccba..57bf3ab7 100644 --- a/lib/push-notifications.js +++ b/lib/push-notifications.js @@ -130,3 +130,14 @@ export const notifyZapped = async ({ models, id }) => { console.error(err) } } + +export const notifyTerritoryTransfer = async ({ models, sub, to }) => { + try { + await sendUserNotification(to.id, { + title: `~${sub.name} was transferred to you`, + tag: `TERRITORY_TRANSFER-${sub.name}` + }) + } catch (err) { + console.error(err) + } +} diff --git a/lib/validate.js b/lib/validate.js index 571741e9..9e65b5e5 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -431,6 +431,25 @@ export function territorySchema (args) { }) } +export function territoryTransferSchema ({ me, ...args }) { + return object({ + userName: nameValidator + .test({ + name: 'name', + test: async name => { + if (!name || !name.length) return false + return await usernameExists(name, args) + }, + message: 'user does not exist' + }) + .test({ + name: 'name', + test: name => !me || me.name !== name, + message: 'cannot transfer to yourself' + }) + }) +} + export function userSchema (args) { return object({ name: nameValidator diff --git a/prisma/migrations/20240305143404_territory_transfer/migration.sql b/prisma/migrations/20240305143404_territory_transfer/migration.sql new file mode 100644 index 00000000..8f68b4df --- /dev/null +++ b/prisma/migrations/20240305143404_territory_transfer/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "TerritoryTransfer" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "oldUserId" INTEGER NOT NULL, + "newUserId" INTEGER NOT NULL, + "subName" CITEXT NOT NULL, + + CONSTRAINT "TerritoryTransfer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TerritoryTransfer.newUserId_index" ON "TerritoryTransfer"("created_at", "newUserId"); + +-- CreateIndex +CREATE INDEX "TerritoryTransfer.oldUserId_index" ON "TerritoryTransfer"("created_at", "oldUserId"); + +-- AddForeignKey +ALTER TABLE "TerritoryTransfer" ADD CONSTRAINT "TerritoryTransfer_oldUserId_fkey" FOREIGN KEY ("oldUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TerritoryTransfer" ADD CONSTRAINT "TerritoryTransfer_newUserId_fkey" FOREIGN KEY ("newUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TerritoryTransfer" ADD CONSTRAINT "TerritoryTransfer_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8abf72d1..6884165a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,8 @@ model User { SubAct SubAct[] MuteSub MuteSub[] Wallet Wallet[] + TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") + TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -490,6 +492,7 @@ model Sub { SubAct SubAct[] MuteSub MuteSub[] SubSubscription SubSubscription[] + TerritoryTransfer TerritoryTransfer[] @@index([parentName]) @@index([createdAt]) @@ -772,6 +775,20 @@ model Log { @@index([createdAt, name], map: "Log.name_index") } +model TerritoryTransfer { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + oldUserId Int + newUserId Int + subName String @db.Citext + oldUser User @relation("TerritoryTransfer_oldUser", fields: [oldUserId], references: [id], onDelete: Cascade) + newUser User @relation("TerritoryTransfer_newUser", fields: [newUserId], references: [id], onDelete: Cascade) + sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade) + + @@index([createdAt, newUserId], map: "TerritoryTransfer.newUserId_index") + @@index([createdAt, oldUserId], map: "TerritoryTransfer.oldUserId_index") +} + enum EarnType { POST COMMENT diff --git a/sw/eventListener.js b/sw/eventListener.js index f73511a5..a3d925df 100644 --- a/sw/eventListener.js +++ b/sw/eventListener.js @@ -91,7 +91,8 @@ export function onPush (sw) { // if there is no tag or it's a TIP, FORWARDEDTIP or EARN notification // we don't need to merge notifications and thus the notification should be immediately shown using `showNotification` -const immediatelyShowNotification = (tag) => !tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK'].includes(tag.split('-')[0]) +const immediatelyShowNotification = (tag) => + !tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER'].includes(tag.split('-')[0]) const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, nid) => { // sanity check