Skip to content

Commit 9ffc455

Browse files
committed
feat: Add support for scheduled posts
1 parent d13ba03 commit 9ffc455

File tree

11 files changed

+595
-8
lines changed

11 files changed

+595
-8
lines changed

api/paidAction/itemCreate.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySub
33
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
44
import { msatsToSats, satsToMsats } from '@/lib/format'
55
import { GqlInputError } from '@/lib/error'
6+
import { getScheduleAt } from '@/lib/item'
67

78
export const anonable = true
89

@@ -96,6 +97,10 @@ export async function perform (args, context) {
9697
const mentions = await getMentions(args, context)
9798
const itemMentions = await getItemMentions(args, context)
9899

100+
// Check if this is a scheduled post
101+
const scheduleAt = getScheduleAt(data.text)
102+
const isScheduled = !!scheduleAt
103+
99104
// start with median vote
100105
if (me) {
101106
const [row] = await tx.$queryRaw`SELECT
@@ -112,6 +117,8 @@ export async function perform (args, context) {
112117
...data,
113118
...invoiceData,
114119
boost,
120+
isScheduled,
121+
scheduledAt: scheduleAt,
115122
threadSubscriptions: {
116123
createMany: {
117124
data: [

api/paidAction/lib/item.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { USER_ID } from '@/lib/constants'
2-
import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item'
2+
import { deleteReminders, getDeleteAt, getRemindAt, getScheduleAt } from '@/lib/item'
33
import { parseInternalLinks } from '@/lib/url'
44

55
export async function getMentions ({ text }, { me, tx }) {
@@ -46,14 +46,19 @@ export const getItemMentions = async ({ text }, { me, tx }) => {
4646
}
4747

4848
export async function performBotBehavior ({ text, id }, { me, tx }) {
49-
// delete any existing deleteItem or reminder jobs for this item
49+
// delete any existing deleteItem, reminder, or publishScheduledPost jobs for this item
5050
const userId = me?.id || USER_ID.anon
5151
id = Number(id)
5252
await tx.$queryRaw`
5353
DELETE FROM pgboss.job
54-
WHERE name = 'deleteItem'
54+
WHERE (name = 'deleteItem' OR name = 'publishScheduledPost')
5555
AND data->>'id' = ${id}::TEXT
5656
AND state <> 'completed'`
57+
await tx.$queryRaw`
58+
DELETE FROM pgboss.job
59+
WHERE name = 'publishScheduledPost'
60+
AND data->>'itemId' = ${id}::TEXT
61+
AND state <> 'completed'`
5762
await deleteReminders({ id, userId, models: tx })
5863

5964
if (text) {
@@ -85,5 +90,30 @@ export async function performBotBehavior ({ text, id }, { me, tx }) {
8590
}
8691
})
8792
}
93+
94+
const scheduleAt = getScheduleAt(text)
95+
if (scheduleAt) {
96+
// For new items, scheduling info is set during creation
97+
// For updates, we need to update the item
98+
const existingItem = await tx.item.findUnique({ where: { id: Number(id) } })
99+
if (existingItem && !existingItem.isScheduled) {
100+
await tx.item.update({
101+
where: { id: Number(id) },
102+
data: {
103+
isScheduled: true,
104+
scheduledAt: scheduleAt
105+
}
106+
})
107+
}
108+
109+
// Schedule the job to publish the post
110+
await tx.$queryRaw`
111+
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
112+
VALUES (
113+
'publishScheduledPost',
114+
jsonb_build_object('itemId', ${id}::INTEGER),
115+
${scheduleAt}::TIMESTAMP WITH TIME ZONE,
116+
${scheduleAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
117+
}
88118
}
89119
}

api/resolvers/item.js

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ export async function getItem (parent, { id }, { me, models }) {
108108
FROM "Item"
109109
${whereClause(
110110
'"Item".id = $1',
111-
activeOrMine(me)
111+
activeOrMine(me),
112+
scheduledOrMine(me)
112113
)}`
113114
}, Number(id))
114115
return item
@@ -130,6 +131,7 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
130131
'"Item".bio = false',
131132
'"Item".boost > 0',
132133
activeOrMine(),
134+
scheduledOrMine(me),
133135
subClause(sub, 1, 'Item', me, showNsfw),
134136
muteClause(me))}
135137
ORDER BY boost desc, "Item".created_at ASC
@@ -256,6 +258,12 @@ export const activeOrMine = (me) => {
256258
export const muteClause = me =>
257259
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
258260

261+
export const scheduledOrMine = (me) => {
262+
return me
263+
? `("Item"."isScheduled" = false OR "Item"."userId" = ${me.id})`
264+
: '"Item"."isScheduled" = false'
265+
}
266+
259267
const HIDE_NSFW_CLAUSE = '("Sub"."nsfw" = FALSE OR "Sub"."nsfw" IS NULL)'
260268

261269
export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
@@ -411,6 +419,7 @@ export default {
411419
${whereClause(
412420
`"${table}"."userId" = $3`,
413421
activeOrMine(me),
422+
scheduledOrMine(me),
414423
nsfwClause(showNsfw),
415424
typeClause(type),
416425
by === 'boost' && '"Item".boost > 0',
@@ -433,6 +442,7 @@ export default {
433442
'"Item"."deletedAt" IS NULL',
434443
subClause(sub, 4, subClauseTable(type), me, showNsfw),
435444
activeOrMine(me),
445+
scheduledOrMine(me),
436446
await filterClause(me, models, type),
437447
typeClause(type),
438448
muteClause(me)
@@ -457,6 +467,7 @@ export default {
457467
typeClause(type),
458468
whenClause(when, 'Item'),
459469
activeOrMine(me),
470+
scheduledOrMine(me),
460471
await filterClause(me, models, type),
461472
by === 'boost' && '"Item".boost > 0',
462473
muteClause(me))}
@@ -484,6 +495,7 @@ export default {
484495
typeClause(type),
485496
await filterClause(me, models, type),
486497
activeOrMine(me),
498+
scheduledOrMine(me),
487499
muteClause(me))}
488500
${orderByClause('random', me, models, type)}
489501
OFFSET $1
@@ -513,6 +525,7 @@ export default {
513525
'"parentId" IS NULL',
514526
'"Item"."deletedAt" IS NULL',
515527
activeOrMine(me),
528+
scheduledOrMine(me),
516529
'created_at <= $1',
517530
'"pinId" IS NULL',
518531
subClause(sub, 4)
@@ -543,6 +556,7 @@ export default {
543556
'"pinId" IS NOT NULL',
544557
'"parentId" IS NULL',
545558
sub ? '"subName" = $1' : '"subName" IS NULL',
559+
scheduledOrMine(me),
546560
muteClause(me))}
547561
) rank_filter WHERE RANK = 1
548562
ORDER BY position ASC`,
@@ -569,6 +583,7 @@ export default {
569583
'"Item".bio = false',
570584
ad ? `"Item".id <> ${ad.id}` : '',
571585
activeOrMine(me),
586+
scheduledOrMine(me),
572587
await filterClause(me, models, type),
573588
subClause(sub, 3, 'Item', me, showNsfw),
574589
muteClause(me))}
@@ -653,6 +668,7 @@ export default {
653668
${SELECT}
654669
FROM "Item"
655670
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
671+
AND (${scheduledOrMine(me)})
656672
ORDER BY created_at DESC
657673
LIMIT 3`
658674
}, similar)
@@ -733,6 +749,36 @@ export default {
733749
homeMaxBoost: homeAgg._max.boost || 0,
734750
subMaxBoost: subAgg?._max.boost || 0
735751
}
752+
},
753+
scheduledItems: async (parent, { cursor, limit = LIMIT }, { me, models }) => {
754+
if (!me) {
755+
throw new GqlAuthenticationError()
756+
}
757+
758+
const decodedCursor = decodeCursor(cursor)
759+
760+
const items = await itemQueryWithMeta({
761+
me,
762+
models,
763+
query: `
764+
${SELECT}, trim(both ' ' from
765+
coalesce(ltree2text(subpath("path", 0, -1)), '')) AS "ancestorTitles"
766+
FROM "Item"
767+
WHERE "userId" = $1 AND "isScheduled" = true AND "deletedAt" IS NULL
768+
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
769+
AND created_at <= $2::timestamp
770+
ORDER BY "scheduledAt" ASC
771+
OFFSET $3
772+
LIMIT $4`,
773+
orderBy: ''
774+
}, me.id, decodedCursor.time, decodedCursor.offset, limit)
775+
776+
return {
777+
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
778+
items,
779+
pins: [],
780+
ad: null
781+
}
736782
}
737783
},
738784

@@ -1048,6 +1094,94 @@ export default {
10481094
])
10491095

10501096
return result
1097+
},
1098+
cancelScheduledPost: async (parent, { id }, { me, models }) => {
1099+
if (!me) {
1100+
throw new GqlAuthenticationError()
1101+
}
1102+
1103+
const item = await models.item.findUnique({
1104+
where: { id: Number(id) }
1105+
})
1106+
1107+
if (!item) {
1108+
throw new GqlInputError('item not found')
1109+
}
1110+
1111+
if (Number(item.userId) !== Number(me.id)) {
1112+
throw new GqlInputError('item does not belong to you')
1113+
}
1114+
1115+
if (!item.isScheduled) {
1116+
throw new GqlInputError('item is not scheduled')
1117+
}
1118+
1119+
// Cancel the scheduled job
1120+
await models.$queryRaw`
1121+
DELETE FROM pgboss.job
1122+
WHERE name = 'publishScheduledPost'
1123+
AND data->>'itemId' = ${item.id}::TEXT
1124+
AND state <> 'completed'`
1125+
1126+
// Update the item to remove scheduling
1127+
return await models.item.update({
1128+
where: { id: Number(id) },
1129+
data: {
1130+
isScheduled: false,
1131+
scheduledAt: null
1132+
}
1133+
})
1134+
},
1135+
publishScheduledPostNow: async (parent, { id }, { me, models }) => {
1136+
if (!me) {
1137+
throw new GqlAuthenticationError()
1138+
}
1139+
1140+
const item = await models.item.findUnique({
1141+
where: { id: Number(id) }
1142+
})
1143+
1144+
if (!item) {
1145+
throw new GqlInputError('item not found')
1146+
}
1147+
1148+
if (Number(item.userId) !== Number(me.id)) {
1149+
throw new GqlInputError('item does not belong to you')
1150+
}
1151+
1152+
if (!item.isScheduled) {
1153+
throw new GqlInputError('item is not scheduled')
1154+
}
1155+
1156+
// Cancel the scheduled job
1157+
await models.$queryRaw`
1158+
DELETE FROM pgboss.job
1159+
WHERE name = 'publishScheduledPost'
1160+
AND data->>'itemId' = ${item.id}::TEXT
1161+
AND state <> 'completed'`
1162+
1163+
const publishTime = new Date()
1164+
1165+
// Publish immediately with current timestamp
1166+
const updatedItem = await models.item.update({
1167+
where: { id: Number(id) },
1168+
data: {
1169+
isScheduled: false,
1170+
scheduledAt: null,
1171+
createdAt: publishTime,
1172+
updatedAt: publishTime
1173+
}
1174+
})
1175+
1176+
// Refresh cached views
1177+
await models.$executeRaw`REFRESH MATERIALIZED VIEW CONCURRENTLY hot_score_view`
1178+
1179+
// Queue side effects
1180+
await models.$executeRaw`
1181+
INSERT INTO pgboss.job (name, data, startafter)
1182+
VALUES ('schedulePostSideEffects', jsonb_build_object('itemId', ${item.id}::INTEGER), now())`
1183+
1184+
return updatedItem
10511185
}
10521186
},
10531187
ItemAct: {
@@ -1357,7 +1491,8 @@ export default {
13571491
FROM "Item"
13581492
${whereClause(
13591493
'"Item".id = $1',
1360-
`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`
1494+
`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`,
1495+
scheduledOrMine(me)
13611496
)}`
13621497
}, Number(item.rootId))
13631498

@@ -1416,6 +1551,17 @@ export default {
14161551
AND data->>'userId' = ${meId}::TEXT
14171552
AND state = 'created'`
14181553
return reminderJobs[0]?.startafter ?? null
1554+
},
1555+
scheduledAt: async (item, args, { me, models }) => {
1556+
const meId = me?.id ?? USER_ID.anon
1557+
if (meId !== item.userId) {
1558+
// Only show scheduledAt for your own items to keep DB queries minimized
1559+
return null
1560+
}
1561+
return item.scheduledAt
1562+
},
1563+
isScheduled: async (item, args, { me, models }) => {
1564+
return !!item.isScheduled
14191565
}
14201566
}
14211567
}

api/resolvers/search.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
22
import { whenToFrom } from '@/lib/time'
3-
import { getItem, itemQueryWithMeta, SELECT } from './item'
3+
import { getItem, itemQueryWithMeta, SELECT, scheduledOrMine } from './item'
44
import { parse } from 'tldts'
55

66
function queryParts (q) {
@@ -163,7 +163,8 @@ export default {
163163
WITH r(id, rank) AS (VALUES ${values})
164164
${SELECT}, rank
165165
FROM "Item"
166-
JOIN r ON "Item".id = r.id`,
166+
JOIN r ON "Item".id = r.id
167+
WHERE ${scheduledOrMine(me)}`,
167168
orderBy: 'ORDER BY rank ASC'
168169
})
169170

@@ -501,7 +502,8 @@ export default {
501502
WITH r(id, rank) AS (VALUES ${values})
502503
${SELECT}, rank
503504
FROM "Item"
504-
JOIN r ON "Item".id = r.id`,
505+
JOIN r ON "Item".id = r.id
506+
WHERE ${scheduledOrMine(me)}`,
505507
orderBy: 'ORDER BY rank ASC, msats DESC'
506508
})).map((item, i) => {
507509
const e = sitems.body.hits.hits[i]

api/typeDefs/item.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default gql`
1111
auctionPosition(sub: String, id: ID, boost: Int): Int!
1212
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
1313
itemRepetition(parentId: ID): Int!
14+
scheduledItems(cursor: String, limit: Limit): Items
1415
}
1516
1617
type BoostPositions {
@@ -63,6 +64,8 @@ export default gql`
6364
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
6465
pollVote(id: ID!): PollVotePaidAction!
6566
toggleOutlaw(id: ID!): Item!
67+
cancelScheduledPost(id: ID!): Item!
68+
publishScheduledPostNow(id: ID!): Item!
6669
}
6770
6871
type PollVoteResult {
@@ -112,6 +115,8 @@ export default gql`
112115
deletedAt: Date
113116
deleteScheduledAt: Date
114117
reminderScheduledAt: Date
118+
scheduledAt: Date
119+
isScheduled: Boolean!
115120
title: String
116121
searchTitle: String
117122
url: String

0 commit comments

Comments
 (0)