@@ -108,7 +108,8 @@ export async function getItem (parent, { id }, { me, models }) {
108
108
FROM "Item"
109
109
${ whereClause (
110
110
'"Item".id = $1' ,
111
- activeOrMine ( me )
111
+ activeOrMine ( me ) ,
112
+ scheduledOrMine ( me )
112
113
) } `
113
114
} , Number ( id ) )
114
115
return item
@@ -130,6 +131,7 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
130
131
'"Item".bio = false' ,
131
132
'"Item".boost > 0' ,
132
133
activeOrMine ( ) ,
134
+ scheduledOrMine ( me ) ,
133
135
subClause ( sub , 1 , 'Item' , me , showNsfw ) ,
134
136
muteClause ( me ) ) }
135
137
ORDER BY boost desc, "Item".created_at ASC
@@ -256,6 +258,12 @@ export const activeOrMine = (me) => {
256
258
export const muteClause = me =>
257
259
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${ me . id } AND "Mute"."mutedId" = "Item"."userId")` : ''
258
260
261
+ export const scheduledOrMine = ( me ) => {
262
+ return me
263
+ ? `("Item"."isScheduled" = false OR "Item"."userId" = ${ me . id } )`
264
+ : '"Item"."isScheduled" = false'
265
+ }
266
+
259
267
const HIDE_NSFW_CLAUSE = '("Sub"."nsfw" = FALSE OR "Sub"."nsfw" IS NULL)'
260
268
261
269
export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
@@ -411,6 +419,7 @@ export default {
411
419
${ whereClause (
412
420
`"${ table } "."userId" = $3` ,
413
421
activeOrMine ( me ) ,
422
+ scheduledOrMine ( me ) ,
414
423
nsfwClause ( showNsfw ) ,
415
424
typeClause ( type ) ,
416
425
by === 'boost' && '"Item".boost > 0' ,
@@ -433,6 +442,7 @@ export default {
433
442
'"Item"."deletedAt" IS NULL' ,
434
443
subClause ( sub , 4 , subClauseTable ( type ) , me , showNsfw ) ,
435
444
activeOrMine ( me ) ,
445
+ scheduledOrMine ( me ) ,
436
446
await filterClause ( me , models , type ) ,
437
447
typeClause ( type ) ,
438
448
muteClause ( me )
@@ -457,6 +467,7 @@ export default {
457
467
typeClause ( type ) ,
458
468
whenClause ( when , 'Item' ) ,
459
469
activeOrMine ( me ) ,
470
+ scheduledOrMine ( me ) ,
460
471
await filterClause ( me , models , type ) ,
461
472
by === 'boost' && '"Item".boost > 0' ,
462
473
muteClause ( me ) ) }
@@ -484,6 +495,7 @@ export default {
484
495
typeClause ( type ) ,
485
496
await filterClause ( me , models , type ) ,
486
497
activeOrMine ( me ) ,
498
+ scheduledOrMine ( me ) ,
487
499
muteClause ( me ) ) }
488
500
${ orderByClause ( 'random' , me , models , type ) }
489
501
OFFSET $1
@@ -513,6 +525,7 @@ export default {
513
525
'"parentId" IS NULL' ,
514
526
'"Item"."deletedAt" IS NULL' ,
515
527
activeOrMine ( me ) ,
528
+ scheduledOrMine ( me ) ,
516
529
'created_at <= $1' ,
517
530
'"pinId" IS NULL' ,
518
531
subClause ( sub , 4 )
@@ -543,6 +556,7 @@ export default {
543
556
'"pinId" IS NOT NULL' ,
544
557
'"parentId" IS NULL' ,
545
558
sub ? '"subName" = $1' : '"subName" IS NULL' ,
559
+ scheduledOrMine ( me ) ,
546
560
muteClause ( me ) ) }
547
561
) rank_filter WHERE RANK = 1
548
562
ORDER BY position ASC` ,
@@ -569,6 +583,7 @@ export default {
569
583
'"Item".bio = false' ,
570
584
ad ? `"Item".id <> ${ ad . id } ` : '' ,
571
585
activeOrMine ( me ) ,
586
+ scheduledOrMine ( me ) ,
572
587
await filterClause ( me , models , type ) ,
573
588
subClause ( sub , 3 , 'Item' , me , showNsfw ) ,
574
589
muteClause ( me ) ) }
@@ -653,6 +668,7 @@ export default {
653
668
${ SELECT }
654
669
FROM "Item"
655
670
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
671
+ AND (${ scheduledOrMine ( me ) } )
656
672
ORDER BY created_at DESC
657
673
LIMIT 3`
658
674
} , similar )
@@ -733,6 +749,36 @@ export default {
733
749
homeMaxBoost : homeAgg . _max . boost || 0 ,
734
750
subMaxBoost : subAgg ?. _max . boost || 0
735
751
}
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
+ }
736
782
}
737
783
} ,
738
784
@@ -1048,6 +1094,94 @@ export default {
1048
1094
] )
1049
1095
1050
1096
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
1051
1185
}
1052
1186
} ,
1053
1187
ItemAct : {
@@ -1357,7 +1491,8 @@ export default {
1357
1491
FROM "Item"
1358
1492
${ whereClause (
1359
1493
'"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 )
1361
1496
) } `
1362
1497
} , Number ( item . rootId ) )
1363
1498
@@ -1416,6 +1551,17 @@ export default {
1416
1551
AND data->>'userId' = ${ meId } ::TEXT
1417
1552
AND state = 'created'`
1418
1553
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
1419
1565
}
1420
1566
}
1421
1567
}
0 commit comments