add polls

This commit is contained in:
keyan 2022-07-30 08:25:46 -05:00
parent 5ac894baed
commit 82280b0966
26 changed files with 685 additions and 24 deletions

View File

@ -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"

View File

@ -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 `

View File

@ -28,7 +28,7 @@ export default {
}
})
return latest.createdAt
return latest?.createdAt
}
}
}

View File

@ -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

View File

@ -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 (
<OverlayTrigger
placement='bottom'
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText || '1 sat'}

View File

@ -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 (
<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 }) {
// 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))}
>
<FormikForm {...props} noValidate>

View File

@ -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 }) {
<ItemComponent item={item} toc showFwdUser {...props}>
{item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />}
{item.poll && <Poll item={item} />}
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}
</ItemComponent>
)

View File

@ -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 }) {
<Link href={`/items/${item.id}`} passHref>
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>}
</a>
</Link>
{item.url &&

96
components/poll-form.js Normal file
View File

@ -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>
)
}

99
components/poll.js Normal file
View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -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 (
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
{fixed(100000000 / price, 0) + ' sats/$'}
{fixedDecimal(100000000 / price, 0) + ' sats/$'}
</Button>
)
}
@ -81,7 +81,7 @@ export default function Price () {
return (
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
{'$' + fixed(price, 0)}
{'$' + fixedDecimal(price, 0)}
</Button>
)
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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'
}
}

View File

@ -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 (
<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'>
<Button variant='secondary'>link</Button>
</Link>
@ -23,17 +28,27 @@ export function PostForm () {
<Link href='/post?type=discussion'>
<Button variant='secondary'>discussion</Button>
</Link>
{me?.freePosts
? <div className='text-center font-weight-bold mt-3 text-success'>{me.freePosts} free posts left</div>
: null}
<div className='d-flex justify-content-center mt-3'>
<AccordianItem
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>
)
}
if (router.query.type === 'discussion') {
return <DiscussionForm adv />
} else {
} else if (router.query.type === 'link') {
return <LinkForm />
} else {
return <PollForm />
}
}

View File

@ -97,6 +97,9 @@ const COLORS = [
]
function GrowthAreaChart ({ data, xName, title }) {
if (!data || data.length === 0) {
return null
}
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<AreaChart

View File

@ -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;

View File

@ -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;
$$;

View File

@ -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;
$$;

View File

@ -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 {

1
svgs/add-fill.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'`
/*