From 82280b0966d695ced83ec8e66aa70a207416ce23 Mon Sep 17 00:00:00 2001 From: keyan Date: Sat, 30 Jul 2022 08:25:46 -0500 Subject: [PATCH] add polls --- api/resolvers/growth.js | 4 +- api/resolvers/item.js | 79 +++++++++++- api/resolvers/sub.js | 2 +- api/typeDefs/item.js | 17 +++ components/action-tooltip.js | 4 +- components/form.js | 52 +++++++- components/item-full.js | 2 + components/item.js | 2 + components/poll-form.js | 96 +++++++++++++++ components/poll.js | 99 ++++++++++++++++ components/poll.module.css | 45 +++++++ components/price.js | 6 +- fragments/items.js | 11 ++ lib/constants.js | 1 + lib/format.js | 4 + lib/time.js | 22 ++++ pages/post.js | 23 +++- pages/users/forever.js | 3 + .../20220727194641_polls/migration.sql | 55 +++++++++ .../migration.sql | 23 ++++ .../migration.sql | 112 ++++++++++++++++++ prisma/schema.prisma | 42 ++++++- svgs/add-fill.svg | 1 + svgs/bar-chart-horizontal-fill.svg | 1 + svgs/checkbox-circle-fill.svg | 1 + worker/earn.js | 2 +- 26 files changed, 685 insertions(+), 24 deletions(-) create mode 100644 components/poll-form.js create mode 100644 components/poll.js create mode 100644 components/poll.module.css create mode 100644 prisma/migrations/20220727194641_polls/migration.sql create mode 100644 prisma/migrations/20220727194920_poll_functions/migration.sql create mode 100644 prisma/migrations/20220727203003_poll_functions2/migration.sql create mode 100644 svgs/add-fill.svg create mode 100644 svgs/bar-chart-horizontal-fill.svg create mode 100644 svgs/checkbox-circle-fill.svg diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js index 0009ecd1..84b1ddcb 100644 --- a/api/resolvers/growth.js +++ b/api/resolvers/growth.js @@ -34,7 +34,7 @@ export default { return await models.$queryRaw( `SELECT date_trunc('month', "ItemAct".created_at) AS time, sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END) as jobs, - sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees, + sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees, sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END) as boost, sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips FROM "ItemAct" @@ -122,7 +122,7 @@ export default { const [stats] = await models.$queryRaw( `SELECT json_build_array( json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)), - json_build_object('name', 'fees', 'value', sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)), + json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)), json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END)), json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END))) as array FROM "ItemAct" diff --git a/api/resolvers/item.js b/api/resolvers/item.js index d95f9deb..6dbfa555 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -458,6 +458,50 @@ export default { return await createItem(parent, data, { me, models }) } }, + upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + if (boost && boost < BOOST_MIN) { + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) + } + + if (id) { + // TODO: this isn't ever called clientside, we edit like it's a discussion + + const item = await models.item.update({ + where: { id: Number(id) }, + data: { title: title } + }) + + return item + } else { + let fwdUser + if (forward) { + fwdUser = await models.user.findUnique({ where: { name: forward } }) + if (!fwdUser) { + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + } + } + + const [item] = await serialize(models, + models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6) AS "Item"`, + title, text, 1, Number(boost || 0), Number(me.id), options)) + + if (fwdUser) { + await models.item.update({ + where: { id: item.id }, + data: { + fwdUserId: fwdUser.id + } + }) + } + + item.comments = [] + return item + } + }, upsertJob: async (parent, { id, sub, title, company, location, remote, text, url, maxBid, status, logo @@ -534,6 +578,17 @@ export default { updateComment: async (parent, { id, text }, { me, models }) => { return await updateItem(parent, { id, data: { text } }, { me, models }) }, + pollVote: async (parent, { id }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + await serialize(models, + models.$queryRaw(`${SELECT} FROM poll_vote($1, $2) AS "Item"`, + Number(id), Number(me.id))) + + return id + }, act: async (parent, { id, sats }, { me, models }) => { // need to make sure we are logged in if (!me) { @@ -561,7 +616,6 @@ export default { } } }, - Item: { sub: async (item, args, { models }) => { if (!item.subName) { @@ -605,6 +659,27 @@ export default { return prior.id }, + poll: async (item, args, { models, me }) => { + if (!item.pollCost) { + return null + } + + const options = await models.$queryRaw` + SELECT "PollOption".id, option, count("PollVote"."userId") as count, + coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted" + FROM "PollOption" + LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id + WHERE "PollOption"."itemId" = ${item.id} + GROUP BY "PollOption".id + ORDER BY "PollOption".id ASC + ` + const poll = {} + poll.options = options + poll.meVoted = options.some(o => o.meVoted) + poll.count = options.reduce((t, o) => t + o.count, 0) + + return poll + }, user: async (item, args, { models }) => await models.user.findUnique({ where: { id: item.userId } }), fwdUser: async (item, args, { models }) => { @@ -852,7 +927,7 @@ export const SELECT = `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", "Item".company, "Item".location, "Item".remote, - "Item"."subName", "Item".status, "Item"."uploadId", ltree2text("Item"."path") AS "path"` + "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", ltree2text("Item"."path") AS "path"` function newTimedOrderByWeightedSats (num) { return ` diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index f68c6909..0ec79f84 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -28,7 +28,7 @@ export default { } }) - return latest.createdAt + return latest?.createdAt } } } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a02c3e74..931435e3 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -23,9 +23,24 @@ export default gql` upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item! upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! + upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item! createComment(text: String!, parentId: ID!): Item! updateComment(id: ID!, text: String!): Item! act(id: ID!, sats: Int): ItemActResult! + pollVote(id: ID!): ID! + } + + type PollOption { + id: ID, + option: String! + count: Int! + meVoted: Boolean! + } + + type Poll { + meVoted: Boolean! + count: Int! + options: [PollOption!]! } type Items { @@ -67,6 +82,8 @@ export default gql` position: Int prior: Int maxBid: Int + pollCost: Int + poll: Poll company: String location: String remote: Boolean diff --git a/components/action-tooltip.js b/components/action-tooltip.js index f79158e8..5d64eabb 100644 --- a/components/action-tooltip.js +++ b/components/action-tooltip.js @@ -1,7 +1,7 @@ import { useFormikContext } from 'formik' import { OverlayTrigger, Tooltip } from 'react-bootstrap' -export default function ActionTooltip ({ children, notForm, disable, overlayText }) { +export default function ActionTooltip ({ children, notForm, disable, overlayText, placement }) { // if we're in a form, we want to hide tooltip on submit let formik if (!notForm) { @@ -12,7 +12,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText } return ( {overlayText || '1 sat'} diff --git a/components/form.js b/components/form.js index aa3b9ca1..7373bbc3 100644 --- a/components/form.js +++ b/components/form.js @@ -2,14 +2,15 @@ import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' import BootstrapForm from 'react-bootstrap/Form' import Alert from 'react-bootstrap/Alert' -import { Formik, Form as FormikForm, useFormikContext, useField } from 'formik' +import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik' import React, { useEffect, useRef, useState } from 'react' import copy from 'clipboard-copy' import Thumb from '../svgs/thumb-up-fill.svg' -import { Nav } from 'react-bootstrap' +import { Col, Nav } from 'react-bootstrap' import Markdown from '../svgs/markdown-line.svg' import styles from './form.module.css' import Text from '../components/text' +import AddIcon from '../svgs/add-fill.svg' export function SubmitButton ({ children, variant, value, onClick, ...props @@ -201,6 +202,39 @@ export function Input ({ label, groupClassName, ...props }) { ) } +export function VariableInput ({ label, groupClassName, name, hint, max, ...props }) { + return ( + + + {({ form, ...fieldArrayHelpers }) => { + const options = form.values.options + return ( + <> + {options.map((_, i) => ( +
+ + + 1 ? 'optional' : undefined} /> + + {options.length - 1 === i && options.length !== max + ? fieldArrayHelpers.push('')} /> + : null} + +
+ ))} + + ) + }} +
+ {hint && ( + + {hint} + + )} +
+ ) +} + export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) { // React treats radios and checkbox inputs differently other input types, select, and textarea. // Formik does this too! When you specify `type` to useField(), it will @@ -243,11 +277,17 @@ export function Form ({ validationSchema={schema} initialTouched={validateImmediately && initial} validateOnBlur={false} - onSubmit={async (...args) => - onSubmit && onSubmit(...args).then(() => { + onSubmit={async (values, ...args) => + onSubmit && onSubmit(values, ...args).then(() => { if (!storageKeyPrefix) return - Object.keys(...args).forEach(v => - localStorage.removeItem(storageKeyPrefix + '-' + v)) + Object.keys(values).forEach(v => { + localStorage.removeItem(storageKeyPrefix + '-' + v) + if (Array.isArray(values[v])) { + values[v].forEach( + (_, i) => localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)) + } + } + ) }).catch(e => setError(e.message || e))} > diff --git a/components/item-full.js b/components/item-full.js index fb945e38..4d6228a2 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -12,6 +12,7 @@ import { TwitterTweetEmbed } from 'react-twitter-embed' import YouTube from 'react-youtube' import useDarkMode from 'use-dark-mode' import { useState } from 'react' +import Poll from './poll' function BioItem ({ item, handleClick }) { const me = useMe() @@ -87,6 +88,7 @@ function TopLevelItem ({ item, noReply, ...props }) { {item.text && } {item.url && } + {item.poll && } {!noReply && } ) diff --git a/components/item.js b/components/item.js index 7a1a7344..c9bf3ffe 100644 --- a/components/item.js +++ b/components/item.js @@ -8,6 +8,7 @@ import { NOFOLLOW_LIMIT } from '../lib/constants' import Pin from '../svgs/pushpin-fill.svg' import reactStringReplace from 'react-string-replace' import Toc from './table-of-contents' +import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' export function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { @@ -55,6 +56,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { {item.searchTitle ? : item.title} + {item.pollCost && } {item.url && diff --git a/components/poll-form.js b/components/poll-form.js new file mode 100644 index 00000000..766021e0 --- /dev/null +++ b/components/poll-form.js @@ -0,0 +1,96 @@ +import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../components/form' +import { useRouter } from 'next/router' +import * as Yup from 'yup' +import { gql, useApolloClient, useMutation } from '@apollo/client' +import ActionTooltip from '../components/action-tooltip' +import Countdown from './countdown' +import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' +import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH } from '../lib/constants' +import TextareaAutosize from 'react-textarea-autosize' + +export function PollForm ({ item, editThreshold }) { + const router = useRouter() + const client = useApolloClient() + + const [upsertPoll] = useMutation( + gql` + mutation upsertPoll($id: ID, $title: String!, $text: String, + $options: [String!]!, $boost: Int, $forward: String) { + upsertPoll(id: $id, title: $title, text: $text, + options: $options, boost: $boost, forward: $forward) { + id + } + }` + ) + + const PollSchema = Yup.object({ + title: Yup.string().required('required').trim() + .max(MAX_TITLE_LENGTH, + ({ max, value }) => `${Math.abs(max - value.length)} too many`), + options: Yup.array().of( + Yup.string().trim().test('my-test', 'required', function (value) { + return (this.path !== 'options[0]' && this.path !== 'options[1]') || value + }).max(MAX_POLL_CHOICE_LENGTH, + ({ max, value }) => `${Math.abs(max - value.length)} too many`) + ), + ...AdvPostSchema(client) + }) + + return ( +
{ + const optionsFiltered = options.filter(word => word.trim().length > 0) + const { error } = await upsertPoll({ + variables: { + id: item?.id, + boost: Number(boost), + title: title.trim(), + options: optionsFiltered, + ...values + } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + if (item) { + await router.push(`/items/${item.id}`) + } else { + await router.push('/recent') + } + }} + storageKeyPrefix={item ? undefined : 'poll'} + > + + text optional} + name='text' + as={TextareaAutosize} + minRows={2} + /> + + : null} + /> + {!item && } + + {item ? 'save' : 'post'} + + + + ) +} diff --git a/components/poll.js b/components/poll.js new file mode 100644 index 00000000..952c511d --- /dev/null +++ b/components/poll.js @@ -0,0 +1,99 @@ +import { gql, useMutation } from '@apollo/client' +import { Button } from 'react-bootstrap' +import { fixedDecimal } from '../lib/format' +import { timeLeft } from '../lib/time' +import { useMe } from './me' +import styles from './poll.module.css' +import Check from '../svgs/checkbox-circle-fill.svg' +import { signIn } from 'next-auth/client' +import { useFundError } from './fund-error' +import ActionTooltip from './action-tooltip' + +export default function Poll ({ item }) { + const me = useMe() + const { setError } = useFundError() + const [pollVote] = useMutation( + gql` + mutation pollVote($id: ID!) { + pollVote(id: $id) + }`, { + update (cache, { data: { pollVote } }) { + cache.modify({ + id: `Item:${item.id}`, + fields: { + poll (existingPoll) { + const poll = { ...existingPoll } + poll.meVoted = true + poll.count += 1 + return poll + } + } + }) + cache.modify({ + id: `PollOption:${pollVote}`, + fields: { + count (existingCount) { + return existingCount + 1 + }, + meVoted () { + return true + } + } + }) + } + } + ) + + const PollButton = ({ v }) => { + return ( + + + + ) + } + + const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5)) + const mine = item.user.id === me?.id + return ( +
+ {item.poll.options.map(v => + expiresIn && !item.poll.meVoted && !mine + ? + : )} +
{item.poll.count} votes \ {expiresIn ? `${expiresIn} left` : 'poll ended'}
+
+ ) +} + +function PollResult ({ v, progress }) { + return ( +
+ {v.option}{v.meVoted && } + {progress}% +
+
+ ) +} diff --git a/components/poll.module.css b/components/poll.module.css new file mode 100644 index 00000000..b482d810 --- /dev/null +++ b/components/poll.module.css @@ -0,0 +1,45 @@ +.pollButton { + margin-top: .25rem; + display: block; + border: 2px solid var(--info); + border-radius: 2rem; + width: 100%; + max-width: 600px; + padding: 0rem 1.1rem; + height: 2rem; + text-transform: uppercase; +} + +.pollBox { + padding-top: .5rem; + padding-right: 15px; + width: 100%; + max-width: 600px; +} + +.pollResult { + text-transform: uppercase; + position: relative; + width: 100%; + max-width: 600px; + height: 2rem; + margin-top: .25rem; + display: flex; + border-radius: .4rem; +} + +.pollProgress { + content: '\A'; + border-radius: .4rem 0rem 0rem .4rem; + position: absolute; + background: var(--theme-clickToContextColor); + top: 0; + bottom: 0; + left: 0; +} + +.pollResult .pollOption { + align-self: center; + margin-left: .5rem; + display: flex; +} \ No newline at end of file diff --git a/components/price.js b/components/price.js index 51c848df..34350d64 100644 --- a/components/price.js +++ b/components/price.js @@ -1,6 +1,7 @@ import React, { useContext, useEffect, useState } from 'react' import { Button } from 'react-bootstrap' import useSWR from 'swr' +import { fixedDecimal } from '../lib/format' const fetcher = url => fetch(url).then(res => res.json()).catch() @@ -49,7 +50,6 @@ export default function Price () { if (!price) return null - const fixed = (n, f) => Number.parseFloat(n).toFixed(f) const handleClick = () => { if (asSats === 'yep') { localStorage.setItem('asSats', '1btc') @@ -66,7 +66,7 @@ export default function Price () { if (asSats === 'yep') { return ( ) } @@ -81,7 +81,7 @@ export default function Price () { return ( ) } diff --git a/fragments/items.js b/fragments/items.js index e131881a..8be4c001 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -30,6 +30,7 @@ export const ITEM_FIELDS = gql` name baseCost } + pollCost status uploadId mine @@ -93,6 +94,16 @@ export const ITEM_FULL = gql` meComments position text + poll { + meVoted + count + options { + id + option + count + meVoted + } + } comments { ...CommentsRecursive } diff --git a/lib/constants.js b/lib/constants.js index a45ff6cb..ae6e033e 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -11,3 +11,4 @@ export const UPLOAD_TYPES_ALLOW = [ ] export const COMMENT_DEPTH_LIMIT = 10 export const MAX_TITLE_LENGTH = 80 +export const MAX_POLL_CHOICE_LENGTH = 30 diff --git a/lib/format.js b/lib/format.js index 9a2ad2ca..4dfa0da6 100644 --- a/lib/format.js +++ b/lib/format.js @@ -5,3 +5,7 @@ export const formatSats = n => { if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b' if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't' } + +export const fixedDecimal = (n, f) => { + return Number.parseFloat(n).toFixed(f) +} diff --git a/lib/time.js b/lib/time.js index b56b2f1c..bb06a02a 100644 --- a/lib/time.js +++ b/lib/time.js @@ -19,3 +19,25 @@ export function timeSince (timeStamp) { return 'now' } + +export function timeLeft (timeStamp) { + const now = new Date() + const secondsPast = (timeStamp - now.getTime()) / 1000 + + if (secondsPast < 0) { + return false + } + + if (secondsPast < 60) { + return parseInt(secondsPast) + 's' + } + if (secondsPast < 3600) { + return parseInt(secondsPast / 60) + 'm' + } + if (secondsPast <= 86400) { + return parseInt(secondsPast / 3600) + 'h' + } + if (secondsPast > 86400) { + return parseInt(secondsPast / (3600 * 24)) + ' days' + } +} diff --git a/pages/post.js b/pages/post.js index 041a7518..bd055936 100644 --- a/pages/post.js +++ b/pages/post.js @@ -6,6 +6,8 @@ import { useMe } from '../components/me' import { DiscussionForm } from '../components/discussion-form' import { LinkForm } from '../components/link-form' import { getGetServerSideProps } from '../api/ssrApollo' +import AccordianItem from '../components/accordian-item' +import { PollForm } from '../components/poll-form' export const getServerSideProps = getGetServerSideProps() @@ -16,6 +18,9 @@ export function PostForm () { if (!router.query.type) { return (
+ {me?.freePosts + ?
{me.freePosts} free posts left
+ : null} @@ -23,17 +28,27 @@ export function PostForm () { - {me?.freePosts - ?
{me.freePosts} free posts left
- : null} +
+ more
} + body={ + + + + } + /> +
) } if (router.query.type === 'discussion') { return - } else { + } else if (router.query.type === 'link') { return + } else { + return } } diff --git a/pages/users/forever.js b/pages/users/forever.js index aa2d3bfd..7c21f07f 100644 --- a/pages/users/forever.js +++ b/pages/users/forever.js @@ -97,6 +97,9 @@ const COLORS = [ ] function GrowthAreaChart ({ data, xName, title }) { + if (!data || data.length === 0) { + return null + } return ( user_sats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- deduct sats from actor + UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id; + + IF act = 'BOOST' OR act = 'POLL' THEN + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc()); + ELSE + -- add sats to actee's balance and stacked count + UPDATE users + SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000) + WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id); + + -- if they have already voted, this is a tip + IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc()); + ELSE + -- else this is a vote with a possible extra tip + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc()); + act_sats := act_sats - 1; + + -- if we have sats left after vote, leave them as a tip + IF act_sats > 0 THEN + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc()); + END IF; + + RETURN 1; + END IF; + END IF; + + RETURN 0; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 32980bb9..32cd8f11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,8 +56,9 @@ model User { noteInvites Boolean @default(true) noteJobIndicator Boolean @default(true) - Earn Earn[] - Upload Upload[] @relation(name: "Uploads") + Earn Earn[] + Upload Upload[] @relation(name: "Uploads") + PollVote PollVote[] @@index([createdAt]) @@index([inviteId]) @@map(name: "users") @@ -180,7 +181,12 @@ model Item { longitude Float? remote Boolean? - User User[] + // fields for polls + pollCost Int? + + User User[] + PollOption PollOption[] + PollVote PollVote[] @@index([createdAt]) @@index([userId]) @@index([parentId]) @@ -192,10 +198,39 @@ model Item { @@index([path]) } +model PollOption { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + itemId Int + item Item @relation(fields: [itemId], references: [id]) + option String + + PollVote PollVote[] + @@index([itemId]) +} + +model PollVote { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + userId Int + user User @relation(fields: [userId], references: [id]) + itemId Int + item Item @relation(fields: [itemId], references: [id]) + pollOptionId Int + pollOption PollOption @relation(fields: [pollOptionId], references: [id]) + + @@unique([itemId, userId]) + @@index([userId]) + @@index([pollOptionId]) +} + enum PostType { LINK DISCUSSION JOB + POLL } enum RankingType { @@ -232,6 +267,7 @@ enum ItemActType { BOOST TIP STREAM + POLL } model ItemAct { diff --git a/svgs/add-fill.svg b/svgs/add-fill.svg new file mode 100644 index 00000000..3069852f --- /dev/null +++ b/svgs/add-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/bar-chart-horizontal-fill.svg b/svgs/bar-chart-horizontal-fill.svg new file mode 100644 index 00000000..0acc9efa --- /dev/null +++ b/svgs/bar-chart-horizontal-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/checkbox-circle-fill.svg b/svgs/checkbox-circle-fill.svg new file mode 100644 index 00000000..1ff6c04c --- /dev/null +++ b/svgs/checkbox-circle-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/worker/earn.js b/worker/earn.js index 7ad9db1c..1e7c892c 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -16,7 +16,7 @@ function earn ({ models }) { FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE ("ItemAct".act in ('BOOST', 'STREAM') - OR ("ItemAct".act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId")) + OR ("ItemAct".act IN ('VOTE','POLL') AND "Item"."userId" = "ItemAct"."userId")) AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'` /*