diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 19f102c8..ad420ae4 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1384,7 +1384,8 @@ export const SELECT = "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo", - ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed` + ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed, + "Item"."pollExpiresAt"` function topOrderByWeightedSats (me, models) { return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC` diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 73e390fa..c8fc35b7 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -34,7 +34,7 @@ export default gql` upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item! upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item! - upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! + upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): Item! updateNoteId(id: ID!, noteId: String!): Item! upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult! @@ -111,6 +111,7 @@ export default gql` isJob: Boolean! pollCost: Int poll: Poll + pollExpiresAt: Date company: String location: String remote: Boolean diff --git a/components/form.js b/components/form.js index 4144f771..fffa1c3c 100644 --- a/components/form.js +++ b/components/form.js @@ -963,6 +963,38 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to ) } +export function DateTimeInput ({ label, groupClassName, name, ...props }) { + const [, meta] = useField({ ...props, name }) + return ( + +
+ + + {meta.error} + +
+
+ ) +} + +function DateTimePicker ({ name, className, ...props }) { + const [field, , helpers] = useField({ ...props, name }) + return ( + { + helpers.setValue(val) + }} + /> + ) +} + function Client (Component) { return ({ initialValue, ...props }) => { // This component can be used for Formik fields diff --git a/components/poll-form.js b/components/poll-form.js index 817a7da3..aaf4c059 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -1,9 +1,10 @@ -import { Form, Input, MarkdownInput, VariableInput } from '../components/form' +import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '../components/form' import { useRouter } from 'next/router' import { gql, useApolloClient, useMutation } from '@apollo/client' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants' +import { datePivot } from '../lib/time' import { pollSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select' import { useCallback } from 'react' @@ -22,9 +23,9 @@ export function PollForm ({ item, sub, editThreshold, children }) { const [upsertPoll] = useMutation( gql` mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, - $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { + $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String, $pollExpiresAt: Date) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { + options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac, pollExpiresAt: $pollExpiresAt) { id deleteScheduledAt } @@ -66,6 +67,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: item?.title || '', text: item?.text || '', options: initialOptions || ['', ''], + pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} @@ -98,7 +100,14 @@ export function PollForm ({ item, sub, editThreshold, children }) { : null} maxLength={MAX_POLL_CHOICE_LENGTH} /> - + + + ) diff --git a/components/poll.js b/components/poll.js index 987f96ee..9cc67ecb 100644 --- a/components/poll.js +++ b/components/poll.js @@ -80,18 +80,23 @@ export default function Poll ({ item }) { ) } - const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5)) + const hasExpiration = !!item.pollExpiresAt + const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const mine = item.user.id === me?.id + const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine return (
{item.poll.options.map(v => - expiresIn && !item.poll.meVoted && !mine + showPollButton ? : )} -
{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })} \ {expiresIn ? `${expiresIn} left` : 'poll ended'}
+
+ {numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })} + {hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`} +
) } diff --git a/fragments/items.js b/fragments/items.js index 96b67360..0e090fa0 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -52,6 +52,7 @@ export const ITEM_FIELDS = gql` remote subName pollCost + pollExpiresAt status uploadId mine diff --git a/lib/time.js b/lib/time.js index 9e39b4aa..1ccdcca3 100644 --- a/lib/time.js +++ b/lib/time.js @@ -1,3 +1,5 @@ +import { numWithUnits } from './format' + export function timeSince (timeStamp) { const now = new Date() const secondsPast = Math.abs(now.getTime() - timeStamp) / 1000 @@ -62,7 +64,8 @@ export function timeLeft (timeStamp) { return parseInt(secondsPast / 3600) + 'h' } if (secondsPast > 86400) { - return parseInt(secondsPast / (3600 * 24)) + ' days' + const days = parseInt(secondsPast / (3600 * 24)) + return numWithUnits(days, { unitSingular: 'day', unitPlural: 'days' }) } } diff --git a/lib/validate.js b/lib/validate.js index a49098d0..571741e9 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -1,4 +1,4 @@ -import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup' +import { string, ValidationError, number, object, array, addMethod, boolean, date } from 'yup' import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES, @@ -11,6 +11,7 @@ import * as usersFragments from '../fragments/users' import * as subsFragments from '../fragments/subs' import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' import { parseNwcUrl } from './url' +import { datePivot } from './time' const { SUB } = subsFragments const { NAME_QUERY } = usersFragments @@ -396,6 +397,7 @@ export function pollSchema ({ numExistingChoices = 0, ...args }) { message: `at least ${MIN_POLL_NUM_CHOICES} choices required`, test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices }), + pollExpiresAt: date().nullable().min(datePivot(new Date(), { days: 1 }), 'Expiration must be at least 1 day in the future'), ...advPostSchemaMembers(args), ...subSelectSchemaMembers(args) }).test({ diff --git a/prisma/migrations/20240219144139_add_poll_expires_at/migration.sql b/prisma/migrations/20240219144139_add_poll_expires_at/migration.sql new file mode 100644 index 00000000..6abdfb79 --- /dev/null +++ b/prisma/migrations/20240219144139_add_poll_expires_at/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "pollExpiresAt" TIMESTAMP(3); + +UPDATE "Item" +SET "pollExpiresAt" = "created_at" + interval '1 day' +WHERE "pollCost" IS NOT NULL; diff --git a/prisma/migrations/20240219195648_update_update_item_for_poll_expired_at_updates/migration.sql b/prisma/migrations/20240219195648_update_update_item_for_poll_expired_at_updates/migration.sql new file mode 100644 index 00000000..65d0cc5b --- /dev/null +++ b/prisma/migrations/20240219195648_update_update_item_for_poll_expired_at_updates/migration.sql @@ -0,0 +1,98 @@ +CREATE OR REPLACE FUNCTION update_item( + jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[]) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost_msats BIGINT; + item "Item"; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + cost_msats := 0; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + -- delete any old uploads that are no longer attached + DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids); + -- insert any new uploads that are not already attached + INSERT INTO "ItemUpload" ("itemId", "uploadId") + SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING; + END IF; + + IF cost_msats > 0 AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + ELSE + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + IF item.boost > 0 THEN + UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id; + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + IF item.status IS NOT NULL THEN + UPDATE "Item" SET "statusUpdatedAt" = now_utc() + WHERE id = item.id AND status <> item.status; + END IF; + + IF item."pollExpiresAt" IS NULL THEN + UPDATE "Item" SET "pollExpiresAt" = NULL + WHERE id = item.id; + END IF; + + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key) + WHERE key <> 'boost'; + + EXECUTE format($fmt$ + UPDATE "Item" SET (%s) = ( + SELECT %1$s + FROM jsonb_populate_record(NULL::"Item", %L) + ) WHERE id = %L RETURNING * + $fmt$, select_clause, jitem, item.id) INTO item; + + -- Delete any old thread subs if the user is no longer a fwd recipient + DELETE FROM "ThreadSubscription" + WHERE "itemId" = item.id + -- they aren't in the new forward list + AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId") + -- and they are in the old forward list + AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" ); + + -- Automatically subscribe any new forward recipients to the post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward) + EXCEPT + SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id; + + -- Delete all old forward entries, to recreate in next command + DELETE FROM "ItemForward" WHERE "itemId" = item.id; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17ffe337..85fc86ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -380,6 +380,7 @@ model Item { ItemUpload ItemUpload[] uploadId Int? outlawed Boolean @default(false) + pollExpiresAt DateTime? @@index([uploadId]) @@index([bio], map: "Item.bio_index")