diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index d6fb603f7..4f8f554f2 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -3,6 +3,7 @@ import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySub
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error'
+import { getScheduleAt } from '@/lib/item'
export const anonable = true
@@ -96,6 +97,9 @@ export async function perform (args, context) {
const mentions = await getMentions(args, context)
const itemMentions = await getItemMentions(args, context)
+ // Check if this is a scheduled post
+ const scheduleAt = getScheduleAt(data.text)
+
// start with median vote
if (me) {
const [row] = await tx.$queryRaw`SELECT
@@ -112,6 +116,7 @@ export async function perform (args, context) {
...data,
...invoiceData,
boost,
+ scheduledAt: scheduleAt,
threadSubscriptions: {
createMany: {
data: [
diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index de46f2f26..15b49afce 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -39,7 +39,7 @@ function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') {
return `ORDER BY ${sharedSorts},
("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC,
- COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
+ COALESCE("Item"."scheduledAt", "Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
}
if (sort === 'hot') {
@@ -108,7 +108,8 @@ export async function getItem (parent, { id }, { me, models }) {
FROM "Item"
${whereClause(
'"Item".id = $1',
- activeOrMine(me)
+ activeOrMine(me),
+ scheduledOrMine(me)
)}`
}, Number(id))
return item
@@ -130,6 +131,7 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
'"Item".bio = false',
'"Item".boost > 0',
activeOrMine(),
+ scheduledOrMine(me),
subClause(sub, 1, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY boost desc, "Item".created_at ASC
@@ -256,6 +258,12 @@ export const activeOrMine = (me) => {
export const muteClause = me =>
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
+export const scheduledOrMine = (me) => {
+ return me
+ ? `("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= now() OR "Item"."userId" = ${me.id})`
+ : '("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= now())'
+}
+
const HIDE_NSFW_CLAUSE = '("Sub"."nsfw" = FALSE OR "Sub"."nsfw" IS NULL)'
export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
@@ -411,6 +419,7 @@ export default {
${whereClause(
`"${table}"."userId" = $3`,
activeOrMine(me),
+ scheduledOrMine(me),
nsfwClause(showNsfw),
typeClause(type),
by === 'boost' && '"Item".boost > 0',
@@ -433,14 +442,15 @@ export default {
'"Item"."deletedAt" IS NULL',
subClause(sub, 4, subClauseTable(type), me, showNsfw),
activeOrMine(me),
+ scheduledOrMine(me),
await filterClause(me, models, type),
typeClause(type),
muteClause(me)
)}
- ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC
+ ORDER BY COALESCE("Item"."scheduledAt", "Item"."invoicePaidAt", "Item".created_at) DESC
OFFSET $2
LIMIT $3`,
- orderBy: 'ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC'
+ orderBy: 'ORDER BY COALESCE("Item"."scheduledAt", "Item"."invoicePaidAt", "Item".created_at) DESC'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
break
case 'top':
@@ -457,6 +467,7 @@ export default {
typeClause(type),
whenClause(when, 'Item'),
activeOrMine(me),
+ scheduledOrMine(me),
await filterClause(me, models, type),
by === 'boost' && '"Item".boost > 0',
muteClause(me))}
@@ -484,6 +495,7 @@ export default {
typeClause(type),
await filterClause(me, models, type),
activeOrMine(me),
+ scheduledOrMine(me),
muteClause(me))}
${orderByClause('random', me, models, type)}
OFFSET $1
@@ -513,6 +525,7 @@ export default {
'"parentId" IS NULL',
'"Item"."deletedAt" IS NULL',
activeOrMine(me),
+ scheduledOrMine(me),
'created_at <= $1',
'"pinId" IS NULL',
subClause(sub, 4)
@@ -543,6 +556,7 @@ export default {
'"pinId" IS NOT NULL',
'"parentId" IS NULL',
sub ? '"subName" = $1' : '"subName" IS NULL',
+ scheduledOrMine(me),
muteClause(me))}
) rank_filter WHERE RANK = 1
ORDER BY position ASC`,
@@ -569,6 +583,7 @@ export default {
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
+ scheduledOrMine(me),
await filterClause(me, models, type),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
@@ -653,6 +668,7 @@ export default {
${SELECT}
FROM "Item"
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
+ AND (${scheduledOrMine(me)})
ORDER BY created_at DESC
LIMIT 3`
}, similar)
@@ -733,6 +749,36 @@ export default {
homeMaxBoost: homeAgg._max.boost || 0,
subMaxBoost: subAgg?._max.boost || 0
}
+ },
+ scheduledItems: async (parent, { cursor, limit = LIMIT }, { me, models }) => {
+ if (!me) {
+ throw new GqlAuthenticationError()
+ }
+
+ const decodedCursor = decodeCursor(cursor)
+
+ const items = await itemQueryWithMeta({
+ me,
+ models,
+ query: `
+ ${SELECT}, trim(both ' ' from
+ coalesce(ltree2text(subpath("path", 0, -1)), '')) AS "ancestorTitles"
+ FROM "Item"
+ WHERE "userId" = $1 AND "scheduledAt" IS NOT NULL AND "deletedAt" IS NULL
+ AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
+ AND created_at <= $2::timestamp
+ ORDER BY "scheduledAt" ASC
+ OFFSET $3
+ LIMIT $4`,
+ orderBy: ''
+ }, me.id, decodedCursor.time, decodedCursor.offset, limit)
+
+ return {
+ cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
+ items,
+ pins: [],
+ ad: null
+ }
}
},
@@ -1357,7 +1403,8 @@ export default {
FROM "Item"
${whereClause(
'"Item".id = $1',
- `("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`
+ `("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`,
+ scheduledOrMine(me)
)}`
}, Number(item.rootId))
@@ -1416,6 +1463,20 @@ export default {
AND data->>'userId' = ${meId}::TEXT
AND state = 'created'`
return reminderJobs[0]?.startafter ?? null
+ },
+ scheduledAt: async (item, args, { me, models }) => {
+ const meId = me?.id ?? USER_ID.anon
+ if (meId !== item.userId) {
+ // Only show scheduledAt for published scheduled posts (scheduledAt <= now)
+ // For unpublished scheduled posts, only show to owner
+ if (!item.scheduledAt || item.scheduledAt > new Date()) {
+ return null
+ }
+ }
+ return item.scheduledAt
+ },
+ isScheduled: async (item, args, { me, models }) => {
+ return !!item.scheduledAt
}
}
}
diff --git a/api/resolvers/search.js b/api/resolvers/search.js
index 26f79a1d9..363b2e580 100644
--- a/api/resolvers/search.js
+++ b/api/resolvers/search.js
@@ -1,6 +1,6 @@
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time'
-import { getItem, itemQueryWithMeta, SELECT } from './item'
+import { getItem, itemQueryWithMeta, SELECT, scheduledOrMine } from './item'
import { parse } from 'tldts'
function queryParts (q) {
@@ -163,7 +163,8 @@ export default {
WITH r(id, rank) AS (VALUES ${values})
${SELECT}, rank
FROM "Item"
- JOIN r ON "Item".id = r.id`,
+ JOIN r ON "Item".id = r.id
+ WHERE ${scheduledOrMine(me)}`,
orderBy: 'ORDER BY rank ASC'
})
@@ -501,7 +502,8 @@ export default {
WITH r(id, rank) AS (VALUES ${values})
${SELECT}, rank
FROM "Item"
- JOIN r ON "Item".id = r.id`,
+ JOIN r ON "Item".id = r.id
+ WHERE ${scheduledOrMine(me)}`,
orderBy: 'ORDER BY rank ASC, msats DESC'
})).map((item, i) => {
const e = sitems.body.hits.hits[i]
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index a40a99ae1..bbc770302 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -11,6 +11,7 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
+ scheduledItems(cursor: String, limit: Limit): Items
}
type BoostPositions {
@@ -112,6 +113,8 @@ export default gql`
deletedAt: Date
deleteScheduledAt: Date
reminderScheduledAt: Date
+ scheduledAt: Date
+ isScheduled: Boolean!
title: String
searchTitle: String
url: String
diff --git a/components/item-info.js b/components/item-info.js
index ec0a09582..a29d8e2ec 100644
--- a/components/item-info.js
+++ b/components/item-info.js
@@ -138,8 +138,8 @@ export default function ItemInfo ({
{embellishUser}
}
-
- {timeSince(new Date(item.invoicePaidAt || item.createdAt))}
+
+ {timeSince(new Date(item.scheduledAt || item.invoicePaidAt || item.createdAt))}
{item.prior &&
<>
diff --git a/components/item-job.js b/components/item-job.js
index 17d2c7278..7035f5254 100644
--- a/components/item-job.js
+++ b/components/item-job.js
@@ -59,8 +59,8 @@ export default function ItemJob ({ item, toc, rank, children, disableRetry, setD
@{item.user.name}
-
- {timeSince(new Date(item.createdAt))}
+
+ {timeSince(new Date(item.scheduledAt || item.invoicePaidAt || item.createdAt))}
{item.subName &&
diff --git a/fragments/items.js b/fragments/items.js
index 151587a20..644eb8893 100644
--- a/fragments/items.js
+++ b/fragments/items.js
@@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql`
parentId
createdAt
invoicePaidAt
+ scheduledAt
deletedAt
title
url
diff --git a/fragments/paidAction.js b/fragments/paidAction.js
index 60ed16e25..4b5f3ae23 100644
--- a/fragments/paidAction.js
+++ b/fragments/paidAction.js
@@ -23,6 +23,8 @@ const ITEM_PAID_ACTION_FIELDS = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
...CommentFields
comments {
comments {
@@ -39,6 +41,8 @@ const ITEM_PAID_ACTION_FIELDS_NO_CHILD_COMMENTS = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
...CommentFields
}
}
@@ -151,6 +155,8 @@ export const UPSERT_DISCUSSION = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
}
...PaidActionFields
}
@@ -168,6 +174,8 @@ export const UPSERT_JOB = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
}
...PaidActionFields
}
@@ -183,6 +191,8 @@ export const UPSERT_LINK = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
}
...PaidActionFields
}
@@ -200,6 +210,8 @@ export const UPSERT_POLL = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
}
...PaidActionFields
}
@@ -215,6 +227,8 @@ export const UPSERT_BOUNTY = gql`
id
deleteScheduledAt
reminderScheduledAt
+ scheduledAt
+ isScheduled
}
...PaidActionFields
}
diff --git a/lib/apollo.js b/lib/apollo.js
index 3739ba3fd..115679c67 100644
--- a/lib/apollo.js
+++ b/lib/apollo.js
@@ -332,6 +332,12 @@ function getClient (uri) {
return reactiveVar()
}
+ },
+ // Ensure scheduled post state changes are properly reflected in cache
+ scheduledAt: {
+ merge (existing, incoming) {
+ return incoming
+ }
}
}
}
diff --git a/lib/item.js b/lib/item.js
index 7bde10af1..d8a4346df 100644
--- a/lib/item.js
+++ b/lib/item.js
@@ -21,8 +21,14 @@ const reminderPattern = /\B@remindme\s+in\s+(\d+)\s+(second|minute|hour|day|week
const reminderMentionPattern = /\B@remindme/i
+const schedulePattern = /\B@schedule\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi
+
+const scheduleMentionPattern = /\B@schedule/i
+
export const hasDeleteMention = (text) => deleteMentionPattern.test(text ?? '')
+export const hasScheduleMention = (text) => scheduleMentionPattern.test(text ?? '')
+
export const getDeleteCommand = (text) => {
if (!text) return false
const matches = [...text.matchAll(deletePattern)]
@@ -61,6 +67,24 @@ export const getReminderCommand = (text) => {
export const hasReminderCommand = (text) => !!getReminderCommand(text)
+export const getScheduleCommand = (text) => {
+ if (!text) return false
+ const matches = [...text.matchAll(schedulePattern)]
+ const commands = matches?.map(match => ({ number: parseInt(match[1]), unit: match[2] }))
+ return commands.length ? commands[commands.length - 1] : undefined
+}
+
+export const getScheduleAt = (text) => {
+ const command = getScheduleCommand(text)
+ if (command) {
+ const { number, unit } = command
+ return datePivot(new Date(), { [`${unit}s`]: number })
+ }
+ return null
+}
+
+export const hasScheduleCommand = (text) => !!getScheduleCommand(text)
+
export const deleteItemByAuthor = async ({ models, id, item }) => {
if (!item) {
item = await models.item.findUnique({ where: { id: Number(id) } })
diff --git a/lib/rss.js b/lib/rss.js
index 747d1eafb..ff69e4a7c 100644
--- a/lib/rss.js
+++ b/lib/rss.js
@@ -31,7 +31,7 @@ const generateRssItem = (item) => {
${escapeXml(link)}
${guid}
Comments]]>
- ${new Date(item.createdAt).toUTCString()}
+ ${new Date(item.scheduledAt || item.invoicePaidAt || item.createdAt).toUTCString()}
${category}
${item.user.name}
diff --git a/prisma/migrations/20250611000000_scheduled_posts/migration.sql b/prisma/migrations/20250611000000_scheduled_posts/migration.sql
new file mode 100644
index 000000000..2b5b08806
--- /dev/null
+++ b/prisma/migrations/20250611000000_scheduled_posts/migration.sql
@@ -0,0 +1,271 @@
+-- Add field for scheduled posts to the Item model
+ALTER TABLE "Item" ADD COLUMN "scheduledAt" TIMESTAMP(3);
+
+-- Create index for efficient querying of scheduled posts
+CREATE INDEX "Item_scheduledAt_idx" ON "Item"("scheduledAt");
+
+-- Update stored functions to filter out scheduled posts from comments for non-owners
+
+CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
+ _item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit int,
+ _level int, _where text, _order_by text)
+ RETURNS jsonb
+ LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
+$$
+DECLARE
+ result jsonb;
+BEGIN
+ IF _level < 1 THEN
+ RETURN '[]'::jsonb;
+ END IF;
+
+ EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
+ || 'WITH RECURSIVE base AS ( '
+ || ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
+ || ' FROM "Item" '
+ || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
+ || ' WHERE "Item"."parentId" = $1 '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW() OR "Item"."userId" = $3) '
+ || _order_by || ' '
+ || ' LIMIT $4 '
+ || ' OFFSET $5) '
+ || ' UNION ALL '
+ || ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn '
+ || ' FROM "Item" '
+ || ' JOIN base b ON "Item"."parentId" = b.id '
+ || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
+ || ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6) '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW() OR "Item"."userId" = $3)) '
+ || ') '
+ || 'SELECT "Item".*, '
+ || ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
+ || ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
+ || ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
+ || ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
+ || ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
+ || ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
+ || ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
+ || ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
+ || ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
+ || ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
+ || ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
+ || ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
+ || 'FROM base "Item" '
+ || 'JOIN users ON users.id = "Item"."userId" '
+ || ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
+ || ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
+ || ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
+ || ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
+ || 'LEFT JOIN LATERAL ( '
+ || ' SELECT "itemId", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
+ || ' FROM "ItemAct" '
+ || ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
+ || ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
+ || ' WHERE "ItemAct"."userId" = $3 '
+ || ' AND "ItemAct"."itemId" = "Item".id '
+ || ' GROUP BY "ItemAct"."itemId" '
+ || ') "ItemAct" ON true '
+ || 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
+ USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
+
+ EXECUTE ''
+ || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
+ || 'FROM ( '
+ || ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
+ || ' FROM t_item "Item" '
+ || ' WHERE "Item"."parentId" = $1 '
+ || _order_by
+ || ' ) sub '
+ INTO result
+ USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
+
+ RETURN result;
+END
+$$;
+
+CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id int, _level int, _where text, _order_by text)
+ RETURNS jsonb
+ LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
+$$
+DECLARE
+ result jsonb;
+BEGIN
+ IF _level < 1 THEN
+ RETURN '[]'::jsonb;
+ END IF;
+
+ EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
+ || 'WITH RECURSIVE base AS ( '
+ || ' (SELECT "Item".*, 1 as level '
+ || ' FROM "Item" '
+ || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
+ || ' WHERE "Item"."parentId" = $1 '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW() OR "Item"."userId" = $3) '
+ || _order_by || ') '
+ || ' UNION ALL '
+ || ' (SELECT "Item".*, b.level + 1 '
+ || ' FROM "Item" '
+ || ' JOIN base b ON "Item"."parentId" = b.id '
+ || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
+ || ' WHERE b.level < $2 '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW() OR "Item"."userId" = $3)) '
+ || ') '
+ || 'SELECT "Item".*, '
+ || ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
+ || ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
+ || ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
+ || ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
+ || ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
+ || ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
+ || ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
+ || ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
+ || ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
+ || ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
+ || ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
+ || ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
+ || 'FROM base "Item" '
+ || 'JOIN users ON users.id = "Item"."userId" '
+ || ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
+ || ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
+ || ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
+ || ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
+ || 'LEFT JOIN LATERAL ( '
+ || ' SELECT "itemId", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
+ || ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
+ || ' FROM "ItemAct" '
+ || ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
+ || ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
+ || ' WHERE "ItemAct"."userId" = $3 '
+ || ' AND "ItemAct"."itemId" = "Item".id '
+ || ' GROUP BY "ItemAct"."itemId" '
+ || ') "ItemAct" ON true '
+ || _where || ' '
+ USING _item_id, _level, _me_id, _global_seed, _where, _order_by;
+
+ EXECUTE ''
+ || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
+ || 'FROM ( '
+ || ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments '
+ || ' FROM t_item "Item" '
+ || ' WHERE "Item"."parentId" = $1 '
+ || _order_by
+ || ' ) sub '
+ INTO result
+ USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
+
+ RETURN result;
+END
+$$;
+
+CREATE OR REPLACE FUNCTION item_comments(_item_id int, _level int, _where text, _order_by text)
+ RETURNS jsonb
+ LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
+$$
+DECLARE
+ result jsonb;
+BEGIN
+ IF _level < 1 THEN
+ RETURN '[]'::jsonb;
+ END IF;
+
+ EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
+ || 'WITH RECURSIVE base AS ( '
+ || ' (SELECT "Item".*, 1 as level '
+ || ' FROM "Item" '
+ || ' WHERE "Item"."parentId" = $1 '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW()) '
+ || _order_by || ') '
+ || ' UNION ALL '
+ || ' (SELECT "Item".*, b.level + 1 '
+ || ' FROM "Item" '
+ || ' JOIN base b ON "Item"."parentId" = b.id '
+ || ' WHERE b.level < $2 '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW())) '
+ || ') '
+ || 'SELECT "Item".*, '
+ || ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
+ || ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
+ || ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
+ || ' to_jsonb(users.*) AS user '
+ || 'FROM base "Item" '
+ || 'JOIN users ON users.id = "Item"."userId" '
+ || _where || ' '
+ USING _item_id, _level, _where, _order_by;
+
+ EXECUTE ''
+ || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
+ || 'FROM ( '
+ || ' SELECT "Item".*, item_comments("Item".id, $2 - 1, $3, $4) AS comments '
+ || ' FROM t_item "Item" '
+ || ' WHERE "Item"."parentId" = $1 '
+ || _order_by
+ || ' ) sub '
+ INTO result
+ USING _item_id, _level, _where, _order_by;
+
+ RETURN result;
+END
+$$;
+
+CREATE OR REPLACE FUNCTION item_comments_limited(
+ _item_id int, _limit int, _offset int, _grandchild_limit int,
+ _level int, _where text, _order_by text)
+ RETURNS jsonb
+ LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
+$$
+DECLARE
+ result jsonb;
+BEGIN
+ IF _level < 1 THEN
+ RETURN '[]'::jsonb;
+ END IF;
+
+ EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
+ || 'WITH RECURSIVE base AS ( '
+ || ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
+ || ' FROM "Item" '
+ || ' WHERE "Item"."parentId" = $1 '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW()) '
+ || _order_by || ' '
+ || ' LIMIT $2 '
+ || ' OFFSET $3) '
+ || ' UNION ALL '
+ || ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn '
+ || ' FROM "Item" '
+ || ' JOIN base b ON "Item"."parentId" = b.id '
+ || ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4) '
+ || ' AND ("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= NOW())) '
+ || ') '
+ || 'SELECT "Item".*, '
+ || ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
+ || ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
+ || ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
+ || ' to_jsonb(users.*) AS user '
+ || 'FROM base "Item" '
+ || 'JOIN users ON users.id = "Item"."userId" '
+ || 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where || ' '
+ USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
+
+ EXECUTE ''
+ || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
+ || 'FROM ( '
+ || ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
+ || ' FROM t_item "Item" '
+ || ' WHERE "Item"."parentId" = $1 '
+ || _order_by
+ || ' ) sub '
+ INTO result
+ USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
+
+ RETURN result;
+END
+$$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a1be3ede6..942aa0f86 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -562,6 +562,7 @@ model Item {
bio Boolean @default(false)
freebie Boolean @default(false)
deletedAt DateTime?
+ scheduledAt DateTime?
otsFile Bytes?
otsHash String?
imgproxyUrls Json?
@@ -628,6 +629,7 @@ model Item {
@@index([url])
@@index([boost])
@@index([invoicePaidAt])
+ @@index([scheduledAt])
}
// we use this to denormalize a user's aggregated interactions (zaps) with an item