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)
}
}