add polls
This commit is contained in:
parent
5ac894baed
commit
82280b0966
|
@ -34,7 +34,7 @@ export default {
|
||||||
return await models.$queryRaw(
|
return await models.$queryRaw(
|
||||||
`SELECT date_trunc('month', "ItemAct".created_at) AS time,
|
`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 = '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 = 'BOOST' THEN sats ELSE 0 END) as boost,
|
||||||
sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips
|
sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
|
@ -122,7 +122,7 @@ export default {
|
||||||
const [stats] = await models.$queryRaw(
|
const [stats] = await models.$queryRaw(
|
||||||
`SELECT json_build_array(
|
`SELECT json_build_array(
|
||||||
json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)),
|
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', '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
|
json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END))) as array
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
|
|
|
@ -458,6 +458,50 @@ export default {
|
||||||
return await createItem(parent, data, { me, models })
|
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, {
|
upsertJob: async (parent, {
|
||||||
id, sub, title, company, location, remote,
|
id, sub, title, company, location, remote,
|
||||||
text, url, maxBid, status, logo
|
text, url, maxBid, status, logo
|
||||||
|
@ -534,6 +578,17 @@ export default {
|
||||||
updateComment: async (parent, { id, text }, { me, models }) => {
|
updateComment: async (parent, { id, text }, { me, models }) => {
|
||||||
return await updateItem(parent, { id, data: { 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 }) => {
|
act: async (parent, { id, sats }, { me, models }) => {
|
||||||
// need to make sure we are logged in
|
// need to make sure we are logged in
|
||||||
if (!me) {
|
if (!me) {
|
||||||
|
@ -561,7 +616,6 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
Item: {
|
Item: {
|
||||||
sub: async (item, args, { models }) => {
|
sub: async (item, args, { models }) => {
|
||||||
if (!item.subName) {
|
if (!item.subName) {
|
||||||
|
@ -605,6 +659,27 @@ export default {
|
||||||
|
|
||||||
return prior.id
|
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 }) =>
|
user: async (item, args, { models }) =>
|
||||||
await models.user.findUnique({ where: { id: item.userId } }),
|
await models.user.findUnique({ where: { id: item.userId } }),
|
||||||
fwdUser: async (item, args, { models }) => {
|
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,
|
`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".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
||||||
"Item".company, "Item".location, "Item".remote,
|
"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) {
|
function newTimedOrderByWeightedSats (num) {
|
||||||
return `
|
return `
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return latest.createdAt
|
return latest?.createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,9 +23,24 @@ export default gql`
|
||||||
upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item!
|
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,
|
upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean,
|
||||||
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
|
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!
|
createComment(text: String!, parentId: ID!): Item!
|
||||||
updateComment(id: ID!, text: String!): Item!
|
updateComment(id: ID!, text: String!): Item!
|
||||||
act(id: ID!, sats: Int): ItemActResult!
|
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 {
|
type Items {
|
||||||
|
@ -67,6 +82,8 @@ export default gql`
|
||||||
position: Int
|
position: Int
|
||||||
prior: Int
|
prior: Int
|
||||||
maxBid: Int
|
maxBid: Int
|
||||||
|
pollCost: Int
|
||||||
|
poll: Poll
|
||||||
company: String
|
company: String
|
||||||
location: String
|
location: String
|
||||||
remote: Boolean
|
remote: Boolean
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useFormikContext } from 'formik'
|
import { useFormikContext } from 'formik'
|
||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
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
|
// if we're in a form, we want to hide tooltip on submit
|
||||||
let formik
|
let formik
|
||||||
if (!notForm) {
|
if (!notForm) {
|
||||||
|
@ -12,7 +12,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
placement='bottom'
|
placement={placement || 'bottom'}
|
||||||
overlay={
|
overlay={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
{overlayText || '1 sat'}
|
{overlayText || '1 sat'}
|
||||||
|
|
|
@ -2,14 +2,15 @@ import Button from 'react-bootstrap/Button'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
import BootstrapForm from 'react-bootstrap/Form'
|
import BootstrapForm from 'react-bootstrap/Form'
|
||||||
import Alert from 'react-bootstrap/Alert'
|
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 React, { useEffect, useRef, useState } from 'react'
|
||||||
import copy from 'clipboard-copy'
|
import copy from 'clipboard-copy'
|
||||||
import Thumb from '../svgs/thumb-up-fill.svg'
|
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 Markdown from '../svgs/markdown-line.svg'
|
||||||
import styles from './form.module.css'
|
import styles from './form.module.css'
|
||||||
import Text from '../components/text'
|
import Text from '../components/text'
|
||||||
|
import AddIcon from '../svgs/add-fill.svg'
|
||||||
|
|
||||||
export function SubmitButton ({
|
export function SubmitButton ({
|
||||||
children, variant, value, onClick, ...props
|
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 (
|
||||||
|
<FormGroup label={label} className={groupClassName}>
|
||||||
|
<FieldArray name={name}>
|
||||||
|
{({ form, ...fieldArrayHelpers }) => {
|
||||||
|
const options = form.values.options
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{options.map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<BootstrapForm.Row className='mb-2'>
|
||||||
|
<Col>
|
||||||
|
<InputInner name={`${name}[${i}]`} {...props} placeholder={i > 1 ? 'optional' : undefined} />
|
||||||
|
</Col>
|
||||||
|
{options.length - 1 === i && options.length !== max
|
||||||
|
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />
|
||||||
|
: null}
|
||||||
|
</BootstrapForm.Row>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</FieldArray>
|
||||||
|
{hint && (
|
||||||
|
<BootstrapForm.Text>
|
||||||
|
{hint}
|
||||||
|
</BootstrapForm.Text>
|
||||||
|
)}
|
||||||
|
</FormGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) {
|
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) {
|
||||||
// React treats radios and checkbox inputs differently other input types, select, and textarea.
|
// 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
|
// Formik does this too! When you specify `type` to useField(), it will
|
||||||
|
@ -243,11 +277,17 @@ export function Form ({
|
||||||
validationSchema={schema}
|
validationSchema={schema}
|
||||||
initialTouched={validateImmediately && initial}
|
initialTouched={validateImmediately && initial}
|
||||||
validateOnBlur={false}
|
validateOnBlur={false}
|
||||||
onSubmit={async (...args) =>
|
onSubmit={async (values, ...args) =>
|
||||||
onSubmit && onSubmit(...args).then(() => {
|
onSubmit && onSubmit(values, ...args).then(() => {
|
||||||
if (!storageKeyPrefix) return
|
if (!storageKeyPrefix) return
|
||||||
Object.keys(...args).forEach(v =>
|
Object.keys(values).forEach(v => {
|
||||||
localStorage.removeItem(storageKeyPrefix + '-' + 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))}
|
}).catch(e => setError(e.message || e))}
|
||||||
>
|
>
|
||||||
<FormikForm {...props} noValidate>
|
<FormikForm {...props} noValidate>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||||
import YouTube from 'react-youtube'
|
import YouTube from 'react-youtube'
|
||||||
import useDarkMode from 'use-dark-mode'
|
import useDarkMode from 'use-dark-mode'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import Poll from './poll'
|
||||||
|
|
||||||
function BioItem ({ item, handleClick }) {
|
function BioItem ({ item, handleClick }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
@ -87,6 +88,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
||||||
<ItemComponent item={item} toc showFwdUser {...props}>
|
<ItemComponent item={item} toc showFwdUser {...props}>
|
||||||
{item.text && <ItemText item={item} />}
|
{item.text && <ItemText item={item} />}
|
||||||
{item.url && <ItemEmbed item={item} />}
|
{item.url && <ItemEmbed item={item} />}
|
||||||
|
{item.poll && <Poll item={item} />}
|
||||||
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}
|
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}
|
||||||
</ItemComponent>
|
</ItemComponent>
|
||||||
)
|
)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { NOFOLLOW_LIMIT } from '../lib/constants'
|
||||||
import Pin from '../svgs/pushpin-fill.svg'
|
import Pin from '../svgs/pushpin-fill.svg'
|
||||||
import reactStringReplace from 'react-string-replace'
|
import reactStringReplace from 'react-string-replace'
|
||||||
import Toc from './table-of-contents'
|
import Toc from './table-of-contents'
|
||||||
|
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
|
||||||
|
|
||||||
export function SearchTitle ({ title }) {
|
export function SearchTitle ({ title }) {
|
||||||
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
||||||
|
@ -55,6 +56,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} passHref>
|
||||||
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
||||||
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
||||||
|
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
{item.url &&
|
{item.url &&
|
||||||
|
|
|
@ -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 (
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
title: item?.title || '',
|
||||||
|
options: item?.options || ['', ''],
|
||||||
|
...AdvPostInitial
|
||||||
|
}}
|
||||||
|
schema={PollSchema}
|
||||||
|
onSubmit={async ({ boost, title, options, ...values }) => {
|
||||||
|
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'}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label='title'
|
||||||
|
name='title'
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<MarkdownInput
|
||||||
|
topLevel
|
||||||
|
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||||
|
name='text'
|
||||||
|
as={TextareaAutosize}
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
label='choices'
|
||||||
|
name='options'
|
||||||
|
max={5}
|
||||||
|
hint={editThreshold
|
||||||
|
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
|
{!item && <AdvPostForm />}
|
||||||
|
<ActionTooltip>
|
||||||
|
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
|
||||||
|
</ActionTooltip>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<ActionTooltip placement='left' notForm>
|
||||||
|
<Button
|
||||||
|
variant='outline-info' className={styles.pollButton}
|
||||||
|
onClick={me
|
||||||
|
? async () => {
|
||||||
|
try {
|
||||||
|
await pollVote({
|
||||||
|
variables: { id: v.id },
|
||||||
|
optimisticResponse: {
|
||||||
|
pollVote: v.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error.toString().includes('insufficient funds')) {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: signIn}
|
||||||
|
>
|
||||||
|
{v.option}
|
||||||
|
</Button>
|
||||||
|
</ActionTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5))
|
||||||
|
const mine = item.user.id === me?.id
|
||||||
|
return (
|
||||||
|
<div className={styles.pollBox}>
|
||||||
|
{item.poll.options.map(v =>
|
||||||
|
expiresIn && !item.poll.meVoted && !mine
|
||||||
|
? <PollButton key={v.id} v={v} />
|
||||||
|
: <PollResult
|
||||||
|
key={v.id} v={v}
|
||||||
|
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
|
||||||
|
/>)}
|
||||||
|
<div className='text-muted mt-1'>{item.poll.count} votes \ {expiresIn ? `${expiresIn} left` : 'poll ended'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PollResult ({ v, progress }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.pollResult}>
|
||||||
|
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ml-1 align-self-center' width={18} height={18} />}</span>
|
||||||
|
<span className='ml-auto mr-2 align-self-center'>{progress}%</span>
|
||||||
|
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useContext, useEffect, useState } from 'react'
|
import React, { useContext, useEffect, useState } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
|
import { fixedDecimal } from '../lib/format'
|
||||||
|
|
||||||
const fetcher = url => fetch(url).then(res => res.json()).catch()
|
const fetcher = url => fetch(url).then(res => res.json()).catch()
|
||||||
|
|
||||||
|
@ -49,7 +50,6 @@ export default function Price () {
|
||||||
|
|
||||||
if (!price) return null
|
if (!price) return null
|
||||||
|
|
||||||
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (asSats === 'yep') {
|
if (asSats === 'yep') {
|
||||||
localStorage.setItem('asSats', '1btc')
|
localStorage.setItem('asSats', '1btc')
|
||||||
|
@ -66,7 +66,7 @@ export default function Price () {
|
||||||
if (asSats === 'yep') {
|
if (asSats === 'yep') {
|
||||||
return (
|
return (
|
||||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||||
{fixed(100000000 / price, 0) + ' sats/$'}
|
{fixedDecimal(100000000 / price, 0) + ' sats/$'}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -81,7 +81,7 @@ export default function Price () {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||||
{'$' + fixed(price, 0)}
|
{'$' + fixedDecimal(price, 0)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ export const ITEM_FIELDS = gql`
|
||||||
name
|
name
|
||||||
baseCost
|
baseCost
|
||||||
}
|
}
|
||||||
|
pollCost
|
||||||
status
|
status
|
||||||
uploadId
|
uploadId
|
||||||
mine
|
mine
|
||||||
|
@ -93,6 +94,16 @@ export const ITEM_FULL = gql`
|
||||||
meComments
|
meComments
|
||||||
position
|
position
|
||||||
text
|
text
|
||||||
|
poll {
|
||||||
|
meVoted
|
||||||
|
count
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
option
|
||||||
|
count
|
||||||
|
meVoted
|
||||||
|
}
|
||||||
|
}
|
||||||
comments {
|
comments {
|
||||||
...CommentsRecursive
|
...CommentsRecursive
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,3 +11,4 @@ export const UPLOAD_TYPES_ALLOW = [
|
||||||
]
|
]
|
||||||
export const COMMENT_DEPTH_LIMIT = 10
|
export const COMMENT_DEPTH_LIMIT = 10
|
||||||
export const MAX_TITLE_LENGTH = 80
|
export const MAX_TITLE_LENGTH = 80
|
||||||
|
export const MAX_POLL_CHOICE_LENGTH = 30
|
||||||
|
|
|
@ -5,3 +5,7 @@ export const formatSats = n => {
|
||||||
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b'
|
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b'
|
||||||
if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't'
|
if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fixedDecimal = (n, f) => {
|
||||||
|
return Number.parseFloat(n).toFixed(f)
|
||||||
|
}
|
||||||
|
|
22
lib/time.js
22
lib/time.js
|
@ -19,3 +19,25 @@ export function timeSince (timeStamp) {
|
||||||
|
|
||||||
return 'now'
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { useMe } from '../components/me'
|
||||||
import { DiscussionForm } from '../components/discussion-form'
|
import { DiscussionForm } from '../components/discussion-form'
|
||||||
import { LinkForm } from '../components/link-form'
|
import { LinkForm } from '../components/link-form'
|
||||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
|
import AccordianItem from '../components/accordian-item'
|
||||||
|
import { PollForm } from '../components/poll-form'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps()
|
export const getServerSideProps = getGetServerSideProps()
|
||||||
|
|
||||||
|
@ -16,6 +18,9 @@ export function PostForm () {
|
||||||
if (!router.query.type) {
|
if (!router.query.type) {
|
||||||
return (
|
return (
|
||||||
<div className='align-items-center'>
|
<div className='align-items-center'>
|
||||||
|
{me?.freePosts
|
||||||
|
? <div className='text-center font-weight-bold mb-3 text-success'>{me.freePosts} free posts left</div>
|
||||||
|
: null}
|
||||||
<Link href='/post?type=link'>
|
<Link href='/post?type=link'>
|
||||||
<Button variant='secondary'>link</Button>
|
<Button variant='secondary'>link</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -23,17 +28,27 @@ export function PostForm () {
|
||||||
<Link href='/post?type=discussion'>
|
<Link href='/post?type=discussion'>
|
||||||
<Button variant='secondary'>discussion</Button>
|
<Button variant='secondary'>discussion</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{me?.freePosts
|
<div className='d-flex justify-content-center mt-3'>
|
||||||
? <div className='text-center font-weight-bold mt-3 text-success'>{me.freePosts} free posts left</div>
|
<AccordianItem
|
||||||
: null}
|
headerColor='#6c757d'
|
||||||
|
header={<div className='font-weight-bold text-muted'>more</div>}
|
||||||
|
body={
|
||||||
|
<Link href='/post?type=poll'>
|
||||||
|
<Button variant='info'>poll</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (router.query.type === 'discussion') {
|
if (router.query.type === 'discussion') {
|
||||||
return <DiscussionForm adv />
|
return <DiscussionForm adv />
|
||||||
} else {
|
} else if (router.query.type === 'link') {
|
||||||
return <LinkForm />
|
return <LinkForm />
|
||||||
|
} else {
|
||||||
|
return <PollForm />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -97,6 +97,9 @@ const COLORS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function GrowthAreaChart ({ data, xName, title }) {
|
function GrowthAreaChart ({ data, xName, title }) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ItemActType" ADD VALUE 'POLL';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "PostType" ADD VALUE 'POLL';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "pollCost" INTEGER;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PollOption" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"option" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PollVote" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"pollOptionId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollOption.itemId_index" ON "PollOption"("itemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PollVote.itemId_userId_unique" ON "PollVote"("itemId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollVote.userId_index" ON "PollVote"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollVote.pollOptionId_index" ON "PollVote"("pollOptionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollOption" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollVote" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollVote" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollVote" ADD FOREIGN KEY ("pollOptionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- create poll
|
||||||
|
-- charges us to create poll
|
||||||
|
-- adds poll options to poll
|
||||||
|
CREATE OR REPLACE FUNCTION create_poll(title TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := create_item(title, null, null, boost, null, user_id);
|
||||||
|
|
||||||
|
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
|
||||||
|
FOREACH option IN ARRAY options LOOP
|
||||||
|
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
|
@ -0,0 +1,112 @@
|
||||||
|
CREATE OR REPLACE FUNCTION create_poll(title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := create_item(title, null, text, boost, null, user_id);
|
||||||
|
|
||||||
|
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
|
||||||
|
FOREACH option IN ARRAY options LOOP
|
||||||
|
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- create poll vote
|
||||||
|
-- if user hasn't already voted
|
||||||
|
-- charges user item.pollCost
|
||||||
|
-- adds POLL to ItemAct
|
||||||
|
-- adds PollVote
|
||||||
|
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option "PollOption";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT * INTO option FROM "PollOption" where id = option_id;
|
||||||
|
IF option IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'INVALID_POLL_OPTION';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO item FROM "Item" where id = option."itemId";
|
||||||
|
IF item IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item."userId" = user_id THEN
|
||||||
|
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM "PollVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
|
||||||
|
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
|
||||||
|
|
||||||
|
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId", "userId")
|
||||||
|
VALUES (now_utc(), now_utc(), item.id, option_id, user_id);
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_sats INTEGER;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id;
|
||||||
|
IF act_sats > 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;
|
||||||
|
$$;
|
|
@ -56,8 +56,9 @@ model User {
|
||||||
noteInvites Boolean @default(true)
|
noteInvites Boolean @default(true)
|
||||||
noteJobIndicator Boolean @default(true)
|
noteJobIndicator Boolean @default(true)
|
||||||
|
|
||||||
Earn Earn[]
|
Earn Earn[]
|
||||||
Upload Upload[] @relation(name: "Uploads")
|
Upload Upload[] @relation(name: "Uploads")
|
||||||
|
PollVote PollVote[]
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([inviteId])
|
@@index([inviteId])
|
||||||
@@map(name: "users")
|
@@map(name: "users")
|
||||||
|
@ -180,7 +181,12 @@ model Item {
|
||||||
longitude Float?
|
longitude Float?
|
||||||
remote Boolean?
|
remote Boolean?
|
||||||
|
|
||||||
User User[]
|
// fields for polls
|
||||||
|
pollCost Int?
|
||||||
|
|
||||||
|
User User[]
|
||||||
|
PollOption PollOption[]
|
||||||
|
PollVote PollVote[]
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
|
@ -192,10 +198,39 @@ model Item {
|
||||||
@@index([path])
|
@@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 {
|
enum PostType {
|
||||||
LINK
|
LINK
|
||||||
DISCUSSION
|
DISCUSSION
|
||||||
JOB
|
JOB
|
||||||
|
POLL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RankingType {
|
enum RankingType {
|
||||||
|
@ -232,6 +267,7 @@ enum ItemActType {
|
||||||
BOOST
|
BOOST
|
||||||
TIP
|
TIP
|
||||||
STREAM
|
STREAM
|
||||||
|
POLL
|
||||||
}
|
}
|
||||||
|
|
||||||
model ItemAct {
|
model ItemAct {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/></svg>
|
After Width: | Height: | Size: 171 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 3v4H3V3h9zm4 14v4H3v-4h13zm6-7v4H3v-4h19z"/></svg>
|
After Width: | Height: | Size: 183 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.997-6l7.07-7.071-1.414-1.414-5.656 5.657-2.829-2.829-1.414 1.414L11.003 16z"/></svg>
|
After Width: | Height: | Size: 289 B |
|
@ -16,7 +16,7 @@ function earn ({ models }) {
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
WHERE ("ItemAct".act in ('BOOST', 'STREAM')
|
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'`
|
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
Loading…
Reference in New Issue