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