diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 84d63cef..de2d53f4 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -75,7 +75,7 @@ export default { Query: { moreItems: async (parent, { sort, cursor, name, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) - let items; let user + let items; let user; let pins switch (sort) { case 'user': @@ -92,6 +92,7 @@ export default { ${SELECT} FROM "Item" WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2 + AND "pinId" IS NULL ORDER BY created_at DESC OFFSET $3 LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) @@ -110,6 +111,7 @@ export default { FROM "Item" ${timedLeftJoinSats(1)} WHERE "parentId" IS NULL AND created_at <= $1 AND created_at > $3 + AND "pinId" IS NULL ${timedOrderBySats(1)} OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date() - 7)) @@ -121,10 +123,25 @@ export default { FROM "Item" ${timedLeftJoinSats(1)} WHERE "parentId" IS NULL AND created_at <= $1 + AND "pinId" IS NULL ${timedOrderBySats(1)} OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) } + + if (decodedCursor.offset === 0) { + // get pins for the page and return those separately + pins = await models.$queryRaw(`SELECT rank_filter.* + FROM ( + ${SELECT}, + rank() OVER ( + PARTITION BY "pinId" + ORDER BY created_at DESC + ) + FROM "Item" + WHERE "pinId" IS NOT NULL + ) rank_filter WHERE RANK = 1`) + } break case 'top': items = await models.$queryRaw(` @@ -132,6 +149,7 @@ export default { FROM "Item" ${timedLeftJoinSats(1)} WHERE "parentId" IS NULL AND created_at <= $1 + AND "pinId" IS NULL ${topClause(within)} ORDER BY x.sats DESC NULLS LAST, created_at DESC OFFSET $2 @@ -141,7 +159,7 @@ export default { items = await models.$queryRaw(` ${SELECT} FROM "Item" - WHERE "parentId" IS NULL AND created_at <= $1 + WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL ORDER BY created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) @@ -149,7 +167,8 @@ export default { } return { cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, - items + items, + pins } }, moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => { @@ -367,6 +386,18 @@ export default { }, Item: { + position: async (item, args, { models }) => { + if (!item.pinId) { + return null + } + + const pin = await models.pin.findUnique({ where: { id: item.pinId } }) + if (!pin) { + return null + } + + return pin.position + }, user: async (item, args, { models }) => await models.user.findUnique({ where: { id: item.userId } }), ncomments: async (item, args, { models }) => { @@ -591,7 +622,7 @@ function nestComments (flat, parentId) { // we have to do our own query because ltree is unsupported export const SELECT = `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, - "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS "path"` + "Item".text, "Item".url, "Item"."userId", "Item"."parentId", "Item"."pinId", ltree2text("Item"."path") AS "path"` const LEFT_JOIN_SATS_SELECT = 'SELECT i.id, SUM(CASE WHEN "ItemAct".act = \'VOTE\' THEN "ItemAct".sats ELSE 0 END) as sats, SUM(CASE WHEN "ItemAct".act = \'BOOST\' THEN "ItemAct".sats ELSE 0 END) as boost' diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f4163fde..dacb2373 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -34,6 +34,7 @@ export default gql` type Items { cursor: String items: [Item!]! + pins: [Item!] } type Comments { @@ -62,5 +63,6 @@ export default gql` ncomments: Int! comments: [Item!]! path: String + position: Int } ` diff --git a/components/item.js b/components/item.js index a23cfb99..d9829654 100644 --- a/components/item.js +++ b/components/item.js @@ -5,6 +5,7 @@ import UpVote from './upvote' import { useEffect, useRef, useState } from 'react' import Countdown from './countdown' import { NOFOLLOW_LIMIT } from '../lib/constants' +import Pin from '../svgs/pin.svg' export default function Item ({ item, rank, children }) { const mine = item.mine @@ -29,7 +30,7 @@ export default function Item ({ item, rank, children }) { ) :
}
- + {item.position ? : }
@@ -47,8 +48,11 @@ export default function Item ({ item, rank, children }) { }
- 0 ? ` (${item.meSats} from me)` : ''}`}>{item.sats + item.tips} sats - \ + {!item.position && + <> + 0 ? ` (${item.meSats} from me)` : ''}`}>{item.sats + item.tips} sats + \ + } {item.boost > 0 && <> {item.boost} boost diff --git a/components/item.module.css b/components/item.module.css index d1a027f5..bbc62c43 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -16,6 +16,12 @@ flex: 1 0 128px; } +.pin { + fill: var(--theme-body); + stroke: var(--theme-color); + margin-right: .2rem; +} + .linkSmall { width: 128px; display: inline-block; diff --git a/components/items.js b/components/items.js index f048c899..9c8f9c92 100644 --- a/components/items.js +++ b/components/items.js @@ -3,8 +3,9 @@ import Item, { ItemSkeleton } from './item' import styles from './items.module.css' import { MORE_ITEMS } from '../fragments/items' import MoreFooter from './more-footer' +import React from 'react' -export default function Items ({ variables, rank, items, cursor }) { +export default function Items ({ variables, rank, items, pins, cursor }) { const { data, fetchMore } = useQuery(MORE_ITEMS, { variables }) if (!data && !items) { @@ -12,14 +13,19 @@ export default function Items ({ variables, rank, items, cursor }) { } if (data) { - ({ moreItems: { items, cursor } } = data) + ({ moreItems: { items, pins, cursor } } = data) } + const pinMap = pins?.reduce((a, p) => { a[p.position] = p; return a }, {}) + return ( <>
{items.map((item, i) => ( - + + {pinMap && pinMap[i + 1] && } + + ))}
diff --git a/prisma/migrations/20220106220010_pins/migration.sql b/prisma/migrations/20220106220010_pins/migration.sql new file mode 100644 index 00000000..caa73eaf --- /dev/null +++ b/prisma/migrations/20220106220010_pins/migration.sql @@ -0,0 +1,59 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "pinId" INTEGER, +ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "Pin" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "cron" TEXT, + "timezone" TEXT, + "position" INTEGER NOT NULL, + + PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Item" ADD FOREIGN KEY ("pinId") REFERENCES "Pin"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- pin upserts add new pgboss.schedule +CREATE OR REPLACE FUNCTION pin_upsert_trigger_func() RETURNS TRIGGER AS $$ + BEGIN + -- only schedule if pin has new.cron set + IF new.cron IS NOT NULL THEN + -- pgboss updates when inserts have the same name + INSERT INTO pgboss.schedule (name, cron, timezone) + VALUES ('repin-' || new.id, new.cron, new.timezone) + ON CONFLICT (name) DO UPDATE SET + cron = EXCLUDED.cron, + timezone = EXCLUDED.timezone, + data = EXCLUDED.data, + options = EXCLUDED.options, + updated_on = now(); + -- if old.cron is set but new.cron isn't ... we need to delete the job + ELSIF old.cron IS NOT NULL AND new.cron IS NULL THEN + DELETE FROM pgboss.schedule where name = 'repin-' || new.id; + END IF; + + RETURN new; + END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS pin_upsert_trigger ON "Pin"; +CREATE TRIGGER pin_upsert_trigger + AFTER INSERT OR UPDATE ON "Pin" + FOR EACH ROW EXECUTE PROCEDURE pin_upsert_trigger_func(); + +-- pin delete removes from pgboss.schedule +CREATE OR REPLACE FUNCTION pin_delete_trigger_func() RETURNS TRIGGER AS $$ + BEGIN + DELETE FROM pgboss.schedule where name = 'repin-' || old.id; + RETURN NULL; + END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS pin_delete_trigger ON "Pin"; +CREATE TRIGGER pin_delete_trigger + AFTER DELETE ON "Pin" + FOR EACH ROW EXECUTE PROCEDURE pin_delete_trigger_func(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aef7744f..2c987bc9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,7 +83,7 @@ model Message { model Item { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") title String? text String? url String? @@ -95,13 +95,27 @@ model Item { actions ItemAct[] mentions Mention[] path Unsupported("LTREE")? + pin Pin? @relation(fields: [pinId], references: [id]) + pinId Int? User User[] @relation("Item") + @@index([createdAt]) @@index([userId]) @@index([parentId]) } +// the active pin is the latest one when it's a recurring cron +model Pin { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + cron String? + timezone String? + position Int + Item Item[] +} + enum ItemActType { VOTE BOOST diff --git a/svgs/pin.svg b/svgs/pin.svg new file mode 100644 index 00000000..cf94b064 --- /dev/null +++ b/svgs/pin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/worker/index.js b/worker/index.js index 39387b6b..8781ba9b 100644 --- a/worker/index.js +++ b/worker/index.js @@ -21,9 +21,49 @@ async function work () { await boss.start() await boss.work('checkInvoice', checkInvoice) await boss.work('checkWithdrawal', checkWithdrawal) + await boss.work('repin-*', repin) console.log('working jobs') } +async function repin ({ name }) { + console.log(name) + // get the id + const id = name.slice('repin-'.length) + if (id.length === 0 || isNaN(id)) { + console.log('repin id not found in', name) + return + } + + // get the latest item with this id + const pinId = Number(id) + const current = await models.item.findFirst( + { + where: { + pinId + }, + orderBy: { + createdAt: 'desc' + } + } + ) + + if (!current) { + console.log('could not find existing item for', name) + return + } + + // create a new item with matching 1) title, text, and url and 2) setting pinId + await models.item.create({ + data: { + title: current.title, + text: current.text, + url: current.url, + userId: current.userId, + pinId + } + }) +} + async function checkInvoice ({ data: { hash } }) { const inv = await getInvoice({ id: hash, lnd }) console.log(inv) @@ -44,7 +84,7 @@ async function checkInvoice ({ data: { hash } }) { })) } else if (new Date(inv.expires_at) > new Date()) { // not expired, recheck in 5 seconds - boss.send('checkInvoice', { hash }, walletOptions) + await boss.send('checkInvoice', { hash }, walletOptions) } } @@ -83,7 +123,7 @@ async function checkWithdrawal ({ data: { id, hash } }) { SELECT reverse_withdrawl(${id}, ${status})`) } else { // we need to requeue to check again in 5 seconds - boss.send('checkWithdrawal', { id, hash }, walletOptions) + await boss.send('checkWithdrawal', { id, hash }, walletOptions) } }